| use std::sync::Arc; |
|
|
| use forge_domain::{TerminalCommand, TerminalContext}; |
|
|
| use crate::EnvironmentInfra; |
|
|
| |
| |
| pub const ENV_TERM_COMMANDS: &str = "_FORGE_TERM_COMMANDS"; |
|
|
| |
| |
| pub const ENV_TERM_EXIT_CODES: &str = "_FORGE_TERM_EXIT_CODES"; |
|
|
| |
| |
| pub const ENV_TERM_TIMESTAMPS: &str = "_FORGE_TERM_TIMESTAMPS"; |
|
|
| |
| |
| |
| |
| |
| pub const ENV_LIST_SEPARATOR: char = '\x1F'; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| #[derive(Clone)] |
| pub struct TerminalContextService<S>(Arc<S>); |
|
|
| impl<S> TerminalContextService<S> { |
| |
| |
| pub fn new(infra: Arc<S>) -> Self { |
| Self(infra) |
| } |
| } |
|
|
| impl<S: EnvironmentInfra<Config = forge_config::ForgeConfig>> TerminalContextService<S> { |
| |
| |
| |
| |
| |
| |
| pub fn get_terminal_context(&self) -> Option<TerminalContext> { |
| let commands_raw = self.0.get_env_var(ENV_TERM_COMMANDS)?; |
|
|
| let commands: Vec<String> = split_env_list(&commands_raw); |
| if commands.is_empty() { |
| return None; |
| } |
|
|
| let exit_codes_raw = self.0.get_env_var(ENV_TERM_EXIT_CODES).unwrap_or_default(); |
| let timestamps_raw = self.0.get_env_var(ENV_TERM_TIMESTAMPS).unwrap_or_default(); |
|
|
| let exit_codes: Vec<i32> = split_env_list(&exit_codes_raw) |
| .iter() |
| .map(|s| s.parse::<i32>().unwrap_or(0)) |
| .collect(); |
|
|
| let timestamps: Vec<u64> = split_env_list(×tamps_raw) |
| .iter() |
| .map(|s| s.parse::<u64>().unwrap_or(0)) |
| .collect(); |
| |
| |
| |
| let mut entries: Vec<TerminalCommand> = commands |
| .into_iter() |
| .zip(exit_codes.into_iter().chain(std::iter::repeat(0))) |
| .zip(timestamps.into_iter().chain(std::iter::repeat(0))) |
| .map(|((command, exit_code), timestamp)| TerminalCommand { |
| command, |
| exit_code, |
| timestamp, |
| }) |
| .collect(); |
|
|
| |
| entries.sort_by_key(|e| e.timestamp); |
|
|
| if entries.is_empty() { |
| None |
| } else { |
| Some(TerminalContext { commands: entries }) |
| } |
| } |
| } |
|
|
| |
| |
| pub fn split_env_list(raw: &str) -> Vec<String> { |
| raw.split(ENV_LIST_SEPARATOR) |
| .filter(|s| !s.is_empty()) |
| .map(String::from) |
| .collect() |
| } |
|
|
| #[cfg(test)] |
| mod tests { |
| use std::collections::BTreeMap; |
| use std::sync::Arc; |
|
|
| use forge_domain::{Environment, TerminalCommand, TerminalContext}; |
| use pretty_assertions::assert_eq; |
|
|
| use super::*; |
|
|
| struct MockInfra { |
| env_vars: BTreeMap<String, String>, |
| } |
|
|
| impl MockInfra { |
| fn new(vars: &[(&str, &str)]) -> Arc<Self> { |
| Arc::new(Self { |
| env_vars: vars |
| .iter() |
| .map(|(k, v)| (k.to_string(), v.to_string())) |
| .collect(), |
| }) |
| } |
| } |
|
|
| impl crate::EnvironmentInfra for MockInfra { |
| type Config = forge_config::ForgeConfig; |
|
|
| fn get_environment(&self) -> Environment { |
| use fake::{Fake, Faker}; |
| Faker.fake() |
| } |
|
|
| fn get_config(&self) -> anyhow::Result<forge_config::ForgeConfig> { |
| Ok(forge_config::ForgeConfig::default()) |
| } |
|
|
| async fn update_environment( |
| &self, |
| _ops: Vec<forge_domain::ConfigOperation>, |
| ) -> anyhow::Result<()> { |
| Ok(()) |
| } |
|
|
| fn get_env_var(&self, key: &str) -> Option<String> { |
| self.env_vars.get(key).cloned() |
| } |
|
|
| fn get_env_vars(&self) -> BTreeMap<String, String> { |
| self.env_vars.clone() |
| } |
| } |
|
|
| #[test] |
| fn test_no_env_vars_returns_none() { |
| let fixture = TerminalContextService::new(MockInfra::new(&[])); |
| let actual = fixture.get_terminal_context(); |
| assert_eq!(actual, None); |
| } |
|
|
| #[test] |
| fn test_empty_commands_returns_none() { |
| let fixture = TerminalContextService::new(MockInfra::new(&[(ENV_TERM_COMMANDS, "")])); |
| let actual = fixture.get_terminal_context(); |
| assert_eq!(actual, None); |
| } |
|
|
| #[test] |
| fn test_single_command_no_extras() { |
| let fixture = |
| TerminalContextService::new(MockInfra::new(&[(ENV_TERM_COMMANDS, "cargo build")])); |
| let actual = fixture.get_terminal_context(); |
| let expected = Some(TerminalContext { |
| commands: vec![TerminalCommand { |
| command: "cargo build".to_string(), |
| exit_code: 0, |
| timestamp: 0, |
| }], |
| }); |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_multiple_commands_with_exit_codes_and_timestamps() { |
| let sep = ENV_LIST_SEPARATOR; |
| let fixture = TerminalContextService::new(MockInfra::new(&[ |
| ( |
| ENV_TERM_COMMANDS, |
| &format!("ls{sep}cargo test{sep}git status"), |
| ), |
| (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), |
| ( |
| ENV_TERM_TIMESTAMPS, |
| &format!("1700000001{sep}1700000002{sep}1700000003"), |
| ), |
| ])); |
| let actual = fixture.get_terminal_context(); |
| let expected = Some(TerminalContext { |
| commands: vec![ |
| TerminalCommand { |
| command: "ls".to_string(), |
| exit_code: 0, |
| timestamp: 1700000001, |
| }, |
| TerminalCommand { |
| command: "cargo test".to_string(), |
| exit_code: 1, |
| timestamp: 1700000002, |
| }, |
| TerminalCommand { |
| command: "git status".to_string(), |
| exit_code: 0, |
| timestamp: 1700000003, |
| }, |
| ], |
| }); |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_split_env_list_empty() { |
| let actual = split_env_list(""); |
| let expected: Vec<String> = vec![]; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_split_env_list_single() { |
| let actual = split_env_list("hello"); |
| let expected = vec!["hello".to_string()]; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_split_env_list_multiple() { |
| let sep = ENV_LIST_SEPARATOR; |
| let actual = split_env_list(&format!("a{sep}b{sep}c")); |
| let expected = vec!["a".to_string(), "b".to_string(), "c".to_string()]; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_split_env_list_command_with_colon() { |
| |
| let sep = ENV_LIST_SEPARATOR; |
| let actual = split_env_list(&format!( |
| "curl https://example.com{sep}docker run -p 8080:80 nginx" |
| )); |
| let expected = vec![ |
| "curl https://example.com".to_string(), |
| "docker run -p 8080:80 nginx".to_string(), |
| ]; |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_commands_sorted_by_timestamp_oldest_first() { |
| |
| let sep = ENV_LIST_SEPARATOR; |
| let fixture = TerminalContextService::new(MockInfra::new(&[ |
| ( |
| ENV_TERM_COMMANDS, |
| &format!("git status{sep}cargo test{sep}ls"), |
| ), |
| (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), |
| ( |
| ENV_TERM_TIMESTAMPS, |
| &format!("1700000003{sep}1700000002{sep}1700000001"), |
| ), |
| ])); |
| let actual = fixture.get_terminal_context(); |
| let expected = Some(TerminalContext { |
| commands: vec![ |
| TerminalCommand { |
| command: "ls".to_string(), |
| exit_code: 0, |
| timestamp: 1700000001, |
| }, |
| TerminalCommand { |
| command: "cargo test".to_string(), |
| exit_code: 1, |
| timestamp: 1700000002, |
| }, |
| TerminalCommand { |
| command: "git status".to_string(), |
| exit_code: 0, |
| timestamp: 1700000003, |
| }, |
| ], |
| }); |
| assert_eq!(actual, expected); |
| } |
|
|
| #[test] |
| fn test_all_commands_included() { |
| |
| let sep = ENV_LIST_SEPARATOR; |
| let fixture = TerminalContextService::new(MockInfra::new(&[ |
| ( |
| ENV_TERM_COMMANDS, |
| &format!("ls{sep}cargo test{sep}git status"), |
| ), |
| (ENV_TERM_EXIT_CODES, &format!("0{sep}1{sep}0")), |
| ( |
| ENV_TERM_TIMESTAMPS, |
| &format!("1700000001{sep}1700000002{sep}1700000003"), |
| ), |
| ])); |
| let actual = fixture.get_terminal_context(); |
| assert_eq!(actual.unwrap().commands.len(), 3); |
| } |
| } |
|
|