1use crate::error::{Autom8Error, Result};
2use serde::{Deserialize, Serialize};
3use std::env;
4use std::fs;
5use std::path::PathBuf;
6
7const CONFIG_DIR_NAME: &str = "autom8";
9
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
42pub struct Config {
43 #[serde(default = "default_true")]
48 pub review: bool,
49
50 #[serde(default = "default_true")]
55 pub commit: bool,
56
57 #[serde(default = "default_true")]
62 pub pull_request: bool,
63
64 #[serde(default = "default_false")]
71 pub pull_request_draft: bool,
72
73 #[serde(default = "default_true")]
81 pub worktree: bool,
82
83 #[serde(default = "default_worktree_path_pattern")]
92 pub worktree_path_pattern: String,
93
94 #[serde(default = "default_false")]
102 pub worktree_cleanup: bool,
103}
104
105fn default_worktree_path_pattern() -> String {
107 "{repo}-wt-{branch}".to_string()
108}
109
110fn default_true() -> bool {
112 true
113}
114
115fn default_false() -> bool {
117 false
118}
119
120impl Default for Config {
121 fn default() -> Self {
122 Self {
123 review: true,
124 commit: true,
125 pull_request: true,
126 pull_request_draft: false,
127 worktree: true,
128 worktree_path_pattern: default_worktree_path_pattern(),
129 worktree_cleanup: false,
130 }
131 }
132}
133
134use std::error::Error;
139use std::fmt;
140
141#[derive(Debug, Clone, PartialEq, Eq)]
147pub enum ConfigError {
148 PullRequestWithoutCommit,
153}
154
155impl fmt::Display for ConfigError {
156 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
157 match self {
158 ConfigError::PullRequestWithoutCommit => {
159 write!(
160 f,
161 "Cannot create pull request without commits. \
162 Either set `commit = true` or set `pull_request = false`"
163 )
164 }
165 }
166 }
167}
168
169impl Error for ConfigError {}
170
171pub fn validate_config(config: &Config) -> std::result::Result<(), ConfigError> {
208 if config.pull_request && !config.commit {
210 return Err(ConfigError::PullRequestWithoutCommit);
211 }
212
213 Ok(())
214}
215
216const GLOBAL_CONFIG_FILENAME: &str = "config.toml";
222
223const DEFAULT_CONFIG_WITH_COMMENTS: &str = r#"# Autom8 Configuration
228# This file controls which states in the autom8 state machine are executed.
229
230# Review state: Code review before committing
231# - true: Run code review step to check implementation quality
232# - false: Skip code review and proceed directly to commit
233review = true
234
235# Commit state: Creating git commits
236# - true: Automatically commit changes after implementation
237# - false: Leave changes uncommitted (manual commit required)
238commit = true
239
240# Pull request state: Creating pull requests
241# - true: Automatically create a PR after committing
242# - false: Skip PR creation (commits remain on local branch)
243# Note: Requires commit = true to work
244pull_request = true
245
246# Pull request draft mode: Create PRs as drafts
247# - true: Create PRs as drafts (not ready for review)
248# - false: Create PRs as regular (ready for review) PRs (default)
249# Note: Only applies when pull_request = true. Has no effect otherwise.
250pull_request_draft = false
251
252# Worktree mode: Automatic worktree creation for parallel runs
253# - true: Create a dedicated worktree for each run (enables parallel sessions, default)
254# - false: Run on the current branch (single session per project)
255# Note: Requires a git repository. Has no effect outside of git repos.
256worktree = true
257
258# Worktree path pattern: Pattern for naming worktree directories
259# Placeholders: {repo} = repository name, {branch} = branch name (slugified)
260# Default: {repo}-wt-{branch} (e.g., "myproject-wt-feature-login")
261worktree_path_pattern = "{repo}-wt-{branch}"
262
263# Worktree cleanup: Automatically remove worktrees after successful completion
264# - true: Remove worktree directory after run completes successfully
265# - false: Preserve worktrees for manual inspection/cleanup (default)
266# Note: Failed runs always keep their worktrees. Only applies when worktree = true.
267worktree_cleanup = false
268"#;
269
270pub fn global_config_path() -> Result<PathBuf> {
274 Ok(config_dir()?.join(GLOBAL_CONFIG_FILENAME))
275}
276
277pub fn load_global_config() -> Result<Config> {
294 let config_path = global_config_path()?;
295
296 if !config_path.exists() {
297 ensure_config_dir()?;
299
300 fs::write(&config_path, DEFAULT_CONFIG_WITH_COMMENTS)?;
302
303 return Ok(Config::default());
304 }
305
306 let content = fs::read_to_string(&config_path)?;
308 let config: Config = toml::from_str(&content).map_err(|e| {
309 Autom8Error::Config(format!(
310 "Failed to parse config file at {:?}: {}",
311 config_path, e
312 ))
313 })?;
314
315 Ok(config)
316}
317
318pub fn save_global_config(config: &Config) -> Result<()> {
334 let config_path = global_config_path()?;
335
336 ensure_config_dir()?;
338
339 let content = generate_config_with_comments(config);
341
342 fs::write(&config_path, content)?;
343
344 Ok(())
345}
346
347fn generate_config_with_comments(config: &Config) -> String {
352 format!(
353 r#"# Autom8 Configuration
354# This file controls which states in the autom8 state machine are executed.
355
356# Review state: Code review before committing
357# - true: Run code review step to check implementation quality
358# - false: Skip code review and proceed directly to commit
359review = {}
360
361# Commit state: Creating git commits
362# - true: Automatically commit changes after implementation
363# - false: Leave changes uncommitted (manual commit required)
364commit = {}
365
366# Pull request state: Creating pull requests
367# - true: Automatically create a PR after committing
368# - false: Skip PR creation (commits remain on local branch)
369# Note: Requires commit = true to work
370pull_request = {}
371
372# Pull request draft mode: Create PRs as drafts
373# - true: Create PRs as drafts (not ready for review)
374# - false: Create PRs as regular (ready for review) PRs (default)
375# Note: Only applies when pull_request = true. Has no effect otherwise.
376pull_request_draft = {}
377
378# Worktree mode: Automatic worktree creation for parallel runs
379# - true: Create a dedicated worktree for each run (enables parallel sessions, default)
380# - false: Run on the current branch (single session per project)
381# Note: Requires a git repository. Has no effect outside of git repos.
382worktree = {}
383
384# Worktree path pattern: Pattern for naming worktree directories
385# Placeholders: {{repo}} = repository name, {{branch}} = branch name (slugified)
386# Default: {{repo}}-wt-{{branch}} (e.g., "myproject-wt-feature-login")
387worktree_path_pattern = "{}"
388
389# Worktree cleanup: Automatically remove worktrees after successful completion
390# - true: Remove worktree directory after run completes successfully
391# - false: Preserve worktrees for manual inspection/cleanup (default)
392# Note: Failed runs always keep their worktrees. Only applies when worktree = true.
393worktree_cleanup = {}
394"#,
395 config.review,
396 config.commit,
397 config.pull_request,
398 config.pull_request_draft,
399 config.worktree,
400 config.worktree_path_pattern,
401 config.worktree_cleanup
402 )
403}
404
405const PROJECT_CONFIG_FILENAME: &str = "config.toml";
411
412pub fn project_config_path() -> Result<PathBuf> {
416 Ok(project_config_dir()?.join(PROJECT_CONFIG_FILENAME))
417}
418
419pub fn project_config_path_for(project_name: &str) -> Result<PathBuf> {
423 Ok(project_config_dir_for(project_name)?.join(PROJECT_CONFIG_FILENAME))
424}
425
426pub fn load_project_config() -> Result<Config> {
443 let config_path = project_config_path()?;
444
445 if !config_path.exists() {
446 ensure_project_config_dir()?;
448
449 let global_config = load_global_config()?;
451 let content = generate_config_with_comments(&global_config);
452 fs::write(&config_path, content)?;
453
454 return Ok(global_config);
455 }
456
457 let content = fs::read_to_string(&config_path)?;
459 let config: Config = toml::from_str(&content).map_err(|e| {
460 Autom8Error::Config(format!(
461 "Failed to parse project config file at {:?}: {}",
462 config_path, e
463 ))
464 })?;
465
466 Ok(config)
467}
468
469pub fn save_project_config(config: &Config) -> Result<()> {
485 let config_path = project_config_path()?;
486
487 ensure_project_config_dir()?;
489
490 let content = generate_config_with_comments(config);
492
493 fs::write(&config_path, content)?;
494
495 Ok(())
496}
497
498pub fn save_project_config_for(project_name: &str, config: &Config) -> Result<()> {
515 let config_path = project_config_path_for(project_name)?;
516
517 let config_dir = project_config_dir_for(project_name)?;
519 fs::create_dir_all(&config_dir)?;
520
521 let content = generate_config_with_comments(config);
523
524 fs::write(&config_path, content)?;
525
526 Ok(())
527}
528
529pub fn get_effective_config() -> Result<Config> {
553 let project_config_path = project_config_path()?;
554
555 let config = if project_config_path.exists() {
556 let content = fs::read_to_string(&project_config_path)?;
558 toml::from_str(&content).map_err(|e| {
559 Autom8Error::Config(format!(
560 "Failed to parse project config file at {:?}: {}",
561 project_config_path, e
562 ))
563 })?
564 } else {
565 load_global_config()?
567 };
568
569 validate_config(&config).map_err(|e| Autom8Error::Config(e.to_string()))?;
571
572 Ok(config)
573}
574
575const SPEC_SUBDIR: &str = "spec";
581const RUNS_SUBDIR: &str = "runs";
582const SESSIONS_SUBDIR: &str = "sessions";
583
584const PROJECT_METADATA_FILENAME: &str = "project.json";
586
587#[derive(Debug, Clone, Serialize, Deserialize)]
592pub struct ProjectMetadata {
593 pub repo_path: PathBuf,
595}
596
597pub fn config_dir() -> Result<PathBuf> {
601 let home = dirs::home_dir()
602 .ok_or_else(|| Autom8Error::Config("Could not determine home directory".to_string()))?;
603 Ok(home.join(".config").join(CONFIG_DIR_NAME))
604}
605
606pub fn ensure_config_dir() -> Result<(PathBuf, bool)> {
611 let dir = config_dir()?;
612 let created = !dir.exists();
613 fs::create_dir_all(&dir)?;
614 Ok((dir, created))
615}
616
617pub fn current_project_name() -> Result<String> {
623 if let Ok(Some(repo_name)) = crate::worktree::get_git_repo_name() {
625 return Ok(repo_name);
626 }
627
628 let cwd = env::current_dir().map_err(|e| {
630 Autom8Error::Config(format!("Could not determine current directory: {}", e))
631 })?;
632 cwd.file_name()
633 .and_then(|n| n.to_str())
634 .map(|s| s.to_string())
635 .ok_or_else(|| {
636 Autom8Error::Config("Could not determine project name from path".to_string())
637 })
638}
639
640pub fn project_config_dir() -> Result<PathBuf> {
644 let base = config_dir()?;
645 let project_name = current_project_name()?;
646 Ok(base.join(project_name))
647}
648
649pub fn project_config_dir_for(project_name: &str) -> Result<PathBuf> {
651 let base = config_dir()?;
652 Ok(base.join(project_name))
653}
654
655pub fn ensure_project_config_dir() -> Result<(PathBuf, bool)> {
665 let dir = project_config_dir()?;
666 let created = !dir.exists();
667
668 fs::create_dir_all(dir.join(SPEC_SUBDIR))?;
670 fs::create_dir_all(dir.join(RUNS_SUBDIR))?;
671
672 let metadata_path = dir.join(PROJECT_METADATA_FILENAME);
674 if !metadata_path.exists() {
675 if let Ok(repo_path) = crate::worktree::get_main_repo_root() {
676 let metadata = ProjectMetadata { repo_path };
677 if let Ok(content) = serde_json::to_string_pretty(&metadata) {
678 let _ = fs::write(&metadata_path, content);
679 }
680 }
681 }
682
683 Ok((dir, created))
684}
685
686pub fn get_project_repo_path(project_name: &str) -> Option<PathBuf> {
693 let project_dir = project_config_dir_for(project_name).ok()?;
694 let metadata_path = project_dir.join(PROJECT_METADATA_FILENAME);
695
696 let content = fs::read_to_string(&metadata_path).ok()?;
697 let metadata: ProjectMetadata = serde_json::from_str(&content).ok()?;
698
699 if metadata.repo_path.exists() {
701 Some(metadata.repo_path)
702 } else {
703 None
704 }
705}
706
707pub fn spec_dir() -> Result<PathBuf> {
709 Ok(project_config_dir()?.join(SPEC_SUBDIR))
710}
711
712pub fn runs_dir() -> Result<PathBuf> {
714 Ok(project_config_dir()?.join(RUNS_SUBDIR))
715}
716
717pub fn list_projects() -> Result<Vec<String>> {
722 let base = config_dir()?;
723
724 if !base.exists() {
725 return Ok(Vec::new());
726 }
727
728 let mut projects = Vec::new();
729
730 let entries = fs::read_dir(&base)
731 .map_err(|e| Autom8Error::Config(format!("Could not read config directory: {}", e)))?;
732
733 for entry in entries {
734 let entry = entry
735 .map_err(|e| Autom8Error::Config(format!("Could not read directory entry: {}", e)))?;
736
737 let path = entry.path();
738 if path.is_dir() {
739 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
740 projects.push(name.to_string());
741 }
742 }
743 }
744
745 projects.sort();
746 Ok(projects)
747}
748
749pub fn is_in_config_dir(file_path: &std::path::Path) -> Result<bool> {
755 let base_config = config_dir()?;
756
757 let canonical_file = file_path
759 .canonicalize()
760 .unwrap_or_else(|_| file_path.to_path_buf());
761 let canonical_config = base_config.canonicalize().unwrap_or(base_config);
762
763 Ok(canonical_file.starts_with(&canonical_config))
764}
765
766#[derive(Debug)]
768pub struct MoveResult {
769 pub dest_path: PathBuf,
771 pub was_moved: bool,
773}
774
775pub fn move_to_config_dir(file_path: &std::path::Path) -> Result<MoveResult> {
783 if is_in_config_dir(file_path)? {
785 let canonical = file_path
786 .canonicalize()
787 .unwrap_or_else(|_| file_path.to_path_buf());
788 return Ok(MoveResult {
789 dest_path: canonical,
790 was_moved: false,
791 });
792 }
793
794 let dest_dir = spec_dir()?;
796
797 fs::create_dir_all(&dest_dir)?;
799
800 let filename = file_path
802 .file_name()
803 .ok_or_else(|| Autom8Error::Config("Could not determine filename".to_string()))?;
804 let dest_path = dest_dir.join(filename);
805
806 if fs::rename(file_path, &dest_path).is_err() {
808 fs::copy(file_path, &dest_path)?;
810 fs::remove_file(file_path)?;
811 }
812
813 Ok(MoveResult {
814 dest_path,
815 was_moved: true,
816 })
817}
818
819#[derive(Debug, Clone)]
821pub struct ProjectStatus {
822 pub name: String,
824 pub has_active_run: bool,
826 pub run_status: Option<crate::state::RunStatus>,
828 pub incomplete_spec_count: usize,
830 pub total_spec_count: usize,
832}
833
834impl ProjectStatus {
835 pub fn needs_attention(&self) -> bool {
837 self.has_active_run
838 || self.run_status == Some(crate::state::RunStatus::Failed)
839 || self.incomplete_spec_count > 0
840 }
841
842 pub fn is_idle(&self) -> bool {
844 !self.needs_attention()
845 }
846}
847
848#[derive(Debug, Clone)]
850pub struct ProjectTreeInfo {
851 pub name: String,
853 pub has_active_run: bool,
855 pub run_status: Option<crate::state::RunStatus>,
857 pub spec_count: usize,
859 pub incomplete_spec_count: usize,
861 pub spec_md_count: usize,
863 pub runs_count: usize,
865 pub last_run_date: Option<chrono::DateTime<chrono::Utc>>,
867}
868
869impl ProjectTreeInfo {
870 pub fn status_label(&self) -> &'static str {
872 if self.has_active_run {
873 "running"
874 } else if self.run_status == Some(crate::state::RunStatus::Failed) {
875 "failed"
876 } else if self.incomplete_spec_count > 0 {
877 "incomplete"
878 } else if self.spec_count > 0 {
879 "complete"
880 } else {
881 "empty"
882 }
883 }
884
885 pub fn has_content(&self) -> bool {
887 self.spec_count > 0 || self.spec_md_count > 0 || self.runs_count > 0 || self.has_active_run
888 }
889}
890
891pub fn list_projects_tree() -> Result<Vec<ProjectTreeInfo>> {
896 use crate::spec::Spec;
897 use crate::state::{RunState, RunStatus, SessionMetadata};
898
899 let projects = list_projects()?;
900 let mut tree_info = Vec::new();
901
902 for project_name in projects {
903 let project_dir = project_config_dir_for(&project_name)?;
904
905 let sessions_dir = project_dir.join(SESSIONS_SUBDIR);
908 let mut has_active_run = false;
909 let mut run_status: Option<RunStatus> = None;
910 let mut active_run_started_at: Option<chrono::DateTime<chrono::Utc>> = None;
911
912 if sessions_dir.exists() {
913 if let Ok(entries) = fs::read_dir(&sessions_dir) {
914 for entry in entries.filter_map(|e| e.ok()) {
915 let session_path = entry.path();
916 if !session_path.is_dir() {
917 continue;
918 }
919
920 let metadata_path = session_path.join("metadata.json");
922 if let Ok(content) = fs::read_to_string(&metadata_path) {
923 if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
924 if metadata.is_running {
925 let state_path = session_path.join("state.json");
927 if let Ok(state_content) = fs::read_to_string(&state_path) {
928 if let Ok(state) =
929 serde_json::from_str::<RunState>(&state_content)
930 {
931 if state.status == RunStatus::Running {
932 has_active_run = true;
933 run_status = Some(state.status);
934 active_run_started_at = Some(state.started_at);
935 break;
936 }
937 }
938 }
939 }
940 }
941 }
942 }
943 }
944 }
945
946 let spec_dir = project_dir.join(SPEC_SUBDIR);
948 let mut specs: Vec<PathBuf> = Vec::new();
949 let mut incomplete_count = 0;
950
951 if spec_dir.exists() {
952 if let Ok(entries) = fs::read_dir(&spec_dir) {
953 for entry in entries.filter_map(|e| e.ok()) {
954 let path = entry.path();
955 if path.extension().is_some_and(|e| e == "json") {
956 specs.push(path.clone());
957 if let Ok(spec) = Spec::load(&path) {
958 if spec.is_incomplete() {
959 incomplete_count += 1;
960 }
961 }
962 }
963 }
964 }
965 }
966
967 let spec_md_count = if spec_dir.exists() {
969 fs::read_dir(&spec_dir)
970 .map(|entries| {
971 entries
972 .filter_map(|e| e.ok())
973 .filter(|e| {
974 e.path().is_file()
975 && e.path().extension().is_some_and(|ext| ext == "md")
976 })
977 .count()
978 })
979 .unwrap_or(0)
980 } else {
981 0
982 };
983
984 let runs_dir = project_dir.join(RUNS_SUBDIR);
986 let mut archived_runs: Vec<RunState> = Vec::new();
987
988 if runs_dir.exists() {
989 if let Ok(entries) = fs::read_dir(&runs_dir) {
990 for entry in entries.filter_map(|e| e.ok()) {
991 let path = entry.path();
992 if path.extension().is_some_and(|e| e == "json") {
993 if let Ok(content) = fs::read_to_string(&path) {
994 if let Ok(state) = serde_json::from_str::<RunState>(&content) {
995 archived_runs.push(state);
996 }
997 }
998 }
999 }
1000 }
1001 }
1002 archived_runs.sort_by(|a, b| b.started_at.cmp(&a.started_at));
1004 let runs_count = archived_runs.len();
1005
1006 let last_run_date = if has_active_run {
1010 active_run_started_at
1012 } else {
1013 archived_runs
1015 .first()
1016 .and_then(|r| r.finished_at.or(Some(r.started_at)))
1017 };
1018
1019 tree_info.push(ProjectTreeInfo {
1020 name: project_name,
1021 has_active_run,
1022 run_status,
1023 spec_count: specs.len(),
1024 incomplete_spec_count: incomplete_count,
1025 spec_md_count,
1026 runs_count,
1027 last_run_date,
1028 });
1029 }
1030
1031 Ok(tree_info)
1032}
1033
1034#[derive(Debug, Clone)]
1036pub struct ProjectDescription {
1037 pub name: String,
1039 pub path: PathBuf,
1041 pub has_active_run: bool,
1043 pub run_status: Option<crate::state::RunStatus>,
1045 pub current_story: Option<String>,
1047 pub current_branch: Option<String>,
1049 pub specs: Vec<SpecSummary>,
1051 pub spec_md_count: usize,
1053 pub runs_count: usize,
1055}
1056
1057#[derive(Debug, Clone)]
1059pub struct SpecSummary {
1060 pub filename: String,
1062 pub path: PathBuf,
1064 pub project_name: String,
1066 pub branch_name: String,
1068 pub description: String,
1070 pub stories: Vec<StorySummary>,
1072 pub completed_count: usize,
1074 pub total_count: usize,
1076 pub is_active: bool,
1078}
1079
1080#[derive(Debug, Clone)]
1082pub struct StorySummary {
1083 pub id: String,
1085 pub title: String,
1087 pub passes: bool,
1089}
1090
1091pub fn project_exists(project_name: &str) -> Result<bool> {
1093 let project_dir = project_config_dir_for(project_name)?;
1094 Ok(project_dir.exists())
1095}
1096
1097pub fn get_project_description(project_name: &str) -> Result<Option<ProjectDescription>> {
1101 use crate::spec::Spec;
1102 use crate::state::StateManager;
1103
1104 let project_dir = project_config_dir_for(project_name)?;
1105
1106 if !project_dir.exists() {
1107 return Ok(None);
1108 }
1109
1110 let sm = StateManager::for_project(project_name)?;
1111
1112 let run_state = sm.load_current().ok().flatten();
1114 let has_active_run = run_state
1115 .as_ref()
1116 .map(|s| s.status == crate::state::RunStatus::Running)
1117 .unwrap_or(false);
1118 let run_status = run_state.as_ref().map(|s| s.status);
1119 let current_story = run_state.as_ref().and_then(|s| s.current_story.clone());
1120 let current_branch = run_state.map(|s| s.branch);
1121
1122 let spec_paths = sm.list_specs().unwrap_or_default();
1124 let mut specs = Vec::new();
1125
1126 for spec_path in spec_paths {
1127 if let Ok(spec) = Spec::load(&spec_path) {
1128 let stories: Vec<StorySummary> = spec
1129 .user_stories
1130 .iter()
1131 .map(|s| StorySummary {
1132 id: s.id.clone(),
1133 title: s.title.clone(),
1134 passes: s.passes,
1135 })
1136 .collect();
1137
1138 let completed_count = stories.iter().filter(|s| s.passes).count();
1139 let total_count = stories.len();
1140
1141 let filename = spec_path
1142 .file_name()
1143 .and_then(|n| n.to_str())
1144 .unwrap_or("unknown")
1145 .to_string();
1146
1147 let is_active = has_active_run
1149 && current_branch
1150 .as_ref()
1151 .is_some_and(|b| b == &spec.branch_name);
1152
1153 specs.push(SpecSummary {
1154 filename,
1155 path: spec_path,
1156 project_name: spec.project,
1157 branch_name: spec.branch_name.clone(),
1158 description: spec.description,
1159 stories,
1160 completed_count,
1161 total_count,
1162 is_active,
1163 });
1164 }
1165 }
1166
1167 let spec_dir = project_dir.join(SPEC_SUBDIR);
1169 let spec_md_count = if spec_dir.exists() {
1170 fs::read_dir(&spec_dir)
1171 .map(|entries| {
1172 entries
1173 .filter_map(|e| e.ok())
1174 .filter(|e| {
1175 e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
1176 })
1177 .count()
1178 })
1179 .unwrap_or(0)
1180 } else {
1181 0
1182 };
1183
1184 let runs_count = sm.list_archived().unwrap_or_default().len();
1186
1187 Ok(Some(ProjectDescription {
1188 name: project_name.to_string(),
1189 path: project_dir,
1190 has_active_run,
1191 run_status,
1192 current_story,
1193 current_branch,
1194 specs,
1195 spec_md_count,
1196 runs_count,
1197 }))
1198}
1199
1200pub fn global_status() -> Result<Vec<ProjectStatus>> {
1205 use crate::spec::Spec;
1206 use crate::state::StateManager;
1207
1208 let projects = list_projects()?;
1209 let mut statuses = Vec::new();
1210
1211 for project_name in projects {
1212 let sm = StateManager::for_project(&project_name)?;
1213
1214 let run_state = sm.load_current().ok().flatten();
1216 let has_active_run = run_state
1217 .as_ref()
1218 .map(|s| s.status == crate::state::RunStatus::Running)
1219 .unwrap_or(false);
1220 let run_status = run_state.map(|s| s.status);
1221
1222 let specs = sm.list_specs().unwrap_or_default();
1224 let mut incomplete_count = 0;
1225 let mut total_count = 0;
1226
1227 for spec_path in &specs {
1228 if let Ok(spec) = Spec::load(spec_path) {
1229 total_count += 1;
1230 if spec.is_incomplete() {
1231 incomplete_count += 1;
1232 }
1233 }
1234 }
1235
1236 statuses.push(ProjectStatus {
1237 name: project_name,
1238 has_active_run,
1239 run_status,
1240 incomplete_spec_count: incomplete_count,
1241 total_spec_count: total_count,
1242 });
1243 }
1244
1245 Ok(statuses)
1246}
1247
1248#[cfg(test)]
1250fn global_status_at(base_config_dir: &std::path::Path) -> Result<Vec<ProjectStatus>> {
1251 use crate::spec::Spec;
1252 use crate::state::StateManager;
1253
1254 let projects = list_projects_at(base_config_dir)?;
1255 let mut statuses = Vec::new();
1256
1257 for project_name in projects {
1258 let project_dir = base_config_dir.join(&project_name);
1259 let sm = StateManager::with_dir(project_dir);
1260
1261 let run_state = sm.load_current().ok().flatten();
1263 let has_active_run = run_state
1264 .as_ref()
1265 .map(|s| s.status == crate::state::RunStatus::Running)
1266 .unwrap_or(false);
1267 let run_status = run_state.map(|s| s.status);
1268
1269 let specs = sm.list_specs().unwrap_or_default();
1271 let mut incomplete_count = 0;
1272 let mut total_count = 0;
1273
1274 for spec_path in &specs {
1275 if let Ok(spec) = Spec::load(spec_path) {
1276 total_count += 1;
1277 if spec.is_incomplete() {
1278 incomplete_count += 1;
1279 }
1280 }
1281 }
1282
1283 statuses.push(ProjectStatus {
1284 name: project_name,
1285 has_active_run,
1286 run_status,
1287 incomplete_spec_count: incomplete_count,
1288 total_spec_count: total_count,
1289 });
1290 }
1291
1292 Ok(statuses)
1293}
1294
1295#[cfg(test)]
1300fn list_projects_at(base_config_dir: &std::path::Path) -> Result<Vec<String>> {
1301 if !base_config_dir.exists() {
1302 return Ok(Vec::new());
1303 }
1304
1305 let mut projects = Vec::new();
1306
1307 let entries = fs::read_dir(base_config_dir)
1308 .map_err(|e| Autom8Error::Config(format!("Could not read config directory: {}", e)))?;
1309
1310 for entry in entries {
1311 let entry = entry
1312 .map_err(|e| Autom8Error::Config(format!("Could not read directory entry: {}", e)))?;
1313
1314 let path = entry.path();
1315 if path.is_dir() {
1316 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
1317 projects.push(name.to_string());
1318 }
1319 }
1320 }
1321
1322 projects.sort();
1323 Ok(projects)
1324}
1325
1326#[cfg(test)]
1333fn ensure_config_dir_at(base: &std::path::Path) -> Result<(PathBuf, bool)> {
1334 let dir = base.join(".config").join(CONFIG_DIR_NAME);
1335 let created = !dir.exists();
1336 fs::create_dir_all(&dir)?;
1337 Ok((dir, created))
1338}
1339
1340#[cfg(test)]
1350fn ensure_project_config_dir_at(
1351 base: &std::path::Path,
1352 project_name: &str,
1353) -> Result<(PathBuf, bool)> {
1354 let dir = base
1355 .join(".config")
1356 .join(CONFIG_DIR_NAME)
1357 .join(project_name);
1358 let created = !dir.exists();
1359
1360 fs::create_dir_all(dir.join(SPEC_SUBDIR))?;
1361 fs::create_dir_all(dir.join(RUNS_SUBDIR))?;
1362
1363 Ok((dir, created))
1364}
1365
1366#[cfg(test)]
1372fn spec_dir_at(project_config_dir: &std::path::Path) -> PathBuf {
1373 project_config_dir.join(SPEC_SUBDIR)
1374}
1375
1376#[cfg(test)]
1382fn is_in_config_dir_at(
1383 base_config_dir: &std::path::Path,
1384 file_path: &std::path::Path,
1385) -> Result<bool> {
1386 let canonical_file = file_path
1388 .canonicalize()
1389 .unwrap_or_else(|_| file_path.to_path_buf());
1390 let canonical_config = base_config_dir
1391 .canonicalize()
1392 .unwrap_or_else(|_| base_config_dir.to_path_buf());
1393
1394 Ok(canonical_file.starts_with(&canonical_config))
1395}
1396
1397#[cfg(test)]
1406fn move_to_config_dir_at(
1407 dest_spec_dir: &std::path::Path,
1408 file_path: &std::path::Path,
1409) -> Result<MoveResult> {
1410 if is_in_config_dir_at(dest_spec_dir, file_path)? {
1412 let canonical = file_path
1413 .canonicalize()
1414 .unwrap_or_else(|_| file_path.to_path_buf());
1415 return Ok(MoveResult {
1416 dest_path: canonical,
1417 was_moved: false,
1418 });
1419 }
1420
1421 fs::create_dir_all(dest_spec_dir)?;
1423
1424 let filename = file_path
1426 .file_name()
1427 .ok_or_else(|| Autom8Error::Config("Could not determine filename".to_string()))?;
1428 let dest_path = dest_spec_dir.join(filename);
1429
1430 if fs::rename(file_path, &dest_path).is_err() {
1432 fs::copy(file_path, &dest_path)?;
1434 fs::remove_file(file_path)?;
1435 }
1436
1437 Ok(MoveResult {
1438 dest_path,
1439 was_moved: true,
1440 })
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446 use tempfile::TempDir;
1447
1448 #[test]
1449 fn test_ensure_config_dir_at_creates_directory() {
1450 let temp_dir = TempDir::new().unwrap();
1451 let expected_path = temp_dir.path().join(".config").join("autom8");
1452 assert!(!expected_path.exists());
1453
1454 let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
1455
1456 assert_eq!(path, expected_path);
1457 assert!(created);
1458 assert!(expected_path.exists());
1459 assert!(expected_path.is_dir());
1460 }
1461
1462 #[test]
1463 fn test_ensure_config_dir_at_reports_existing_directory() {
1464 let temp_dir = TempDir::new().unwrap();
1465 let expected_path = temp_dir.path().join(".config").join("autom8");
1466
1467 fs::create_dir_all(&expected_path).unwrap();
1469 assert!(expected_path.exists());
1470
1471 let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
1472
1473 assert_eq!(path, expected_path);
1474 assert!(!created); assert!(expected_path.exists());
1476 }
1477
1478 #[test]
1479 fn test_ensure_config_dir_at_creates_parent_directories() {
1480 let temp_dir = TempDir::new().unwrap();
1481
1482 let config_path = temp_dir.path().join(".config");
1484 assert!(!config_path.exists());
1485
1486 let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
1487
1488 assert!(created);
1489 assert!(path.exists());
1490 assert!(config_path.exists()); }
1492
1493 #[test]
1494 fn test_spec_dir_at_returns_spec_subdirectory() {
1495 let project_config_dir = PathBuf::from("/some/project/config/dir");
1496 let result = spec_dir_at(&project_config_dir);
1497 assert_eq!(result, PathBuf::from("/some/project/config/dir/spec"));
1498 }
1499
1500 #[test]
1501 fn test_spec_dir_at_with_temp_dir() {
1502 let temp_dir = TempDir::new().unwrap();
1503 let (project_dir, _) =
1504 ensure_project_config_dir_at(temp_dir.path(), "test-project").unwrap();
1505
1506 let spec_dir = spec_dir_at(&project_dir);
1507
1508 assert_eq!(spec_dir, project_dir.join("spec"));
1510 assert!(spec_dir.exists());
1512 assert!(spec_dir.is_dir());
1513 }
1514
1515 #[test]
1516 fn test_is_in_config_dir_at_returns_true_for_file_inside_config() {
1517 let temp_dir = TempDir::new().unwrap();
1518 let config_dir = temp_dir.path().join("config");
1519 fs::create_dir_all(&config_dir).unwrap();
1520
1521 let file_inside = config_dir.join("subdir").join("file.txt");
1522 fs::create_dir_all(file_inside.parent().unwrap()).unwrap();
1523 fs::write(&file_inside, "test").unwrap();
1524
1525 let result = is_in_config_dir_at(&config_dir, &file_inside).unwrap();
1526 assert!(result);
1527 }
1528
1529 #[test]
1530 fn test_is_in_config_dir_at_returns_false_for_file_outside_config() {
1531 let temp_dir = TempDir::new().unwrap();
1532 let config_dir = temp_dir.path().join("config");
1533 let other_dir = temp_dir.path().join("other");
1534 fs::create_dir_all(&config_dir).unwrap();
1535 fs::create_dir_all(&other_dir).unwrap();
1536
1537 let file_outside = other_dir.join("file.txt");
1538 fs::write(&file_outside, "test").unwrap();
1539
1540 let result = is_in_config_dir_at(&config_dir, &file_outside).unwrap();
1541 assert!(!result);
1542 }
1543
1544 #[test]
1545 fn test_is_in_config_dir_at_handles_nonexistent_path() {
1546 let temp_dir = TempDir::new().unwrap();
1547 let config_dir = temp_dir.path().join("config");
1548 fs::create_dir_all(&config_dir).unwrap();
1549
1550 let canonical_config = config_dir.canonicalize().unwrap();
1553 let nonexistent_file = canonical_config.join("does_not_exist.txt");
1554
1555 let result = is_in_config_dir_at(&config_dir, &nonexistent_file).unwrap();
1557 assert!(result);
1558 }
1559
1560 #[test]
1561 fn test_move_to_config_dir_at_moves_file_to_dest_dir() {
1562 let temp_dir = TempDir::new().unwrap();
1563 let source_dir = temp_dir.path().join("source");
1564 let dest_spec_dir = temp_dir.path().join("dest_config").join("spec");
1565 fs::create_dir_all(&source_dir).unwrap();
1566
1567 let source_file = source_dir.join("test-file.json");
1568 let content = r#"{"test": "data"}"#;
1569 fs::write(&source_file, content).unwrap();
1570
1571 let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1572
1573 assert!(result.was_moved, "File should have been moved");
1574 assert!(result.dest_path.exists(), "Destination file should exist");
1575 assert!(
1576 !source_file.exists(),
1577 "Source file should be deleted after move"
1578 );
1579 assert!(
1580 result.dest_path.starts_with(&dest_spec_dir),
1581 "File should be in the specified dest_spec_dir"
1582 );
1583 assert_eq!(
1584 fs::read_to_string(&result.dest_path).unwrap(),
1585 content,
1586 "Content should match"
1587 );
1588 }
1589
1590 #[test]
1591 fn test_move_to_config_dir_at_returns_unchanged_if_already_in_dest() {
1592 let temp_dir = TempDir::new().unwrap();
1593 let dest_spec_dir = temp_dir.path().join("config").join("spec");
1594 fs::create_dir_all(&dest_spec_dir).unwrap();
1595
1596 let existing_file = dest_spec_dir.join("already-here.md");
1597 fs::write(&existing_file, "# Already here").unwrap();
1598
1599 let result = move_to_config_dir_at(&dest_spec_dir, &existing_file).unwrap();
1600
1601 assert!(!result.was_moved, "File should not have been moved");
1602 assert!(
1603 existing_file.exists(),
1604 "File should still exist in original location"
1605 );
1606 assert_eq!(
1607 result.dest_path.canonicalize().unwrap(),
1608 existing_file.canonicalize().unwrap(),
1609 "Path should be the canonical original"
1610 );
1611 }
1612
1613 #[test]
1614 fn test_move_to_config_dir_at_preserves_filename() {
1615 let temp_dir = TempDir::new().unwrap();
1616 let source_dir = temp_dir.path().join("source");
1617 let dest_spec_dir = temp_dir.path().join("config").join("spec");
1618 fs::create_dir_all(&source_dir).unwrap();
1619
1620 let source_file = source_dir.join("my-custom-filename.txt");
1621 fs::write(&source_file, "test content").unwrap();
1622
1623 let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1624
1625 assert_eq!(
1626 result.dest_path.file_name().unwrap().to_str().unwrap(),
1627 "my-custom-filename.txt",
1628 "Filename should be preserved"
1629 );
1630 }
1631
1632 #[test]
1633 fn test_move_to_config_dir_at_creates_dest_dir_if_missing() {
1634 let temp_dir = TempDir::new().unwrap();
1635 let source_dir = temp_dir.path().join("source");
1636 let dest_spec_dir = temp_dir
1637 .path()
1638 .join("nonexistent")
1639 .join("nested")
1640 .join("spec");
1641 fs::create_dir_all(&source_dir).unwrap();
1642 let source_file = source_dir.join("test.md");
1645 fs::write(&source_file, "# Test").unwrap();
1646
1647 let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1648
1649 assert!(result.was_moved, "File should have been moved");
1650 assert!(
1651 dest_spec_dir.exists(),
1652 "Destination directory should be created"
1653 );
1654 assert!(result.dest_path.exists(), "Destination file should exist");
1655 }
1656
1657 #[test]
1658 fn test_ensure_project_config_dir_at_creates_all_subdirs() {
1659 let temp_dir = TempDir::new().unwrap();
1660 let project_name = "test-project";
1661
1662 let (path, created) = ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
1663
1664 assert!(created);
1665 assert!(path.exists());
1666 assert!(path.ends_with(project_name));
1667
1668 assert!(path.join("spec").exists());
1670 assert!(path.join("spec").is_dir());
1671 assert!(path.join("runs").exists());
1672 assert!(path.join("runs").is_dir());
1673 }
1674
1675 #[test]
1676 fn test_ensure_project_config_dir_at_reports_existing() {
1677 let temp_dir = TempDir::new().unwrap();
1678 let project_name = "existing-project";
1679
1680 let (path1, created1) =
1682 ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
1683 assert!(created1);
1684
1685 let (path2, created2) =
1687 ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
1688 assert!(!created2);
1689 assert_eq!(path1, path2);
1690 }
1691
1692 #[test]
1693 fn test_ensure_project_config_dir_at_different_projects_share_nothing() {
1694 let temp_dir = TempDir::new().unwrap();
1695
1696 let (path1, _) = ensure_project_config_dir_at(temp_dir.path(), "project-a").unwrap();
1697 let (path2, _) = ensure_project_config_dir_at(temp_dir.path(), "project-b").unwrap();
1698
1699 assert_ne!(path1, path2);
1701 assert!(path1.exists());
1702 assert!(path2.exists());
1703
1704 assert!(path1.join("spec").exists());
1706 assert!(path2.join("spec").exists());
1707 }
1708
1709 #[test]
1710 fn test_ensure_project_config_dir_creates_directory_structure() {
1711 let temp_dir = TempDir::new().unwrap();
1713
1714 let result = ensure_project_config_dir_at(temp_dir.path(), "test-project");
1715 assert!(result.is_ok());
1716 let (path, created) = result.unwrap();
1717
1718 assert!(created);
1720
1721 assert!(path.exists());
1723 assert!(path.join("spec").exists());
1724 assert!(path.join("runs").exists());
1725 }
1726
1727 #[test]
1728 fn test_is_in_config_dir_true_for_file_in_config() {
1729 let temp_dir = TempDir::new().unwrap();
1731 let config_dir = temp_dir.path().join("config");
1732 fs::create_dir_all(&config_dir).unwrap();
1733 let test_file = config_dir.join("test.json");
1734 fs::write(&test_file, "{}").unwrap();
1735
1736 let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
1737 assert!(result, "File in config dir should return true");
1738 }
1739
1740 #[test]
1741 fn test_is_in_config_dir_false_for_file_outside_config() {
1742 let temp_dir = TempDir::new().unwrap();
1743 let test_file = temp_dir.path().join("test.json");
1744 fs::write(&test_file, "{}").unwrap();
1745
1746 let result = is_in_config_dir(&test_file).unwrap();
1747 assert!(!result, "File outside config dir should return false");
1748 }
1749
1750 #[test]
1751 fn test_is_in_config_dir_true_for_file_in_subdirectory() {
1752 let temp_dir = TempDir::new().unwrap();
1754 let config_dir = temp_dir.path().join("config");
1755 let spec_dir = config_dir.join("spec");
1756 fs::create_dir_all(&spec_dir).unwrap();
1757 let test_file = spec_dir.join("test.md");
1758 fs::write(&test_file, "# Test").unwrap();
1759
1760 let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
1761 assert!(result, "File in config subdirectory should return true");
1762 }
1763
1764 #[test]
1765 fn test_is_in_config_dir_true_for_file_in_different_project() {
1766 let temp_dir = TempDir::new().unwrap();
1769 let config_dir = temp_dir.path().join("config");
1770 let other_project_spec_dir = config_dir
1771 .join("some-other-project-wt-feature")
1772 .join("spec");
1773 fs::create_dir_all(&other_project_spec_dir).unwrap();
1774 let test_file = other_project_spec_dir.join("test.md");
1775 fs::write(&test_file, "# Test").unwrap();
1776
1777 let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
1778 assert!(
1779 result,
1780 "File in different project's config dir should return true"
1781 );
1782 }
1783
1784 #[test]
1785 fn test_move_to_config_dir_moves_md_to_spec() {
1786 let temp_dir = TempDir::new().unwrap();
1787 let source_dir = temp_dir.path().join("source");
1788 let dest_spec_dir = temp_dir.path().join("config").join("spec");
1789 fs::create_dir_all(&source_dir).unwrap();
1790
1791 let source_file = source_dir.join("test-spec.md");
1792 let content = "# Test Spec\n\nThis is a test.";
1793 fs::write(&source_file, content).unwrap();
1794
1795 let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1796
1797 assert!(result.was_moved, "File should have been moved");
1798 assert!(result.dest_path.exists(), "Destination file should exist");
1799 assert!(
1800 !source_file.exists(),
1801 "Source file should be deleted after move"
1802 );
1803 assert!(
1804 result.dest_path.parent().unwrap().ends_with("spec"),
1805 "MD files should go to spec/ directory"
1806 );
1807 assert_eq!(
1808 fs::read_to_string(&result.dest_path).unwrap(),
1809 content,
1810 "Content should match"
1811 );
1812 }
1814
1815 #[test]
1816 fn test_move_to_config_dir_no_move_if_already_in_config() {
1817 let temp_dir = TempDir::new().unwrap();
1819 let dest_spec_dir = temp_dir.path().join("config").join("spec");
1820 fs::create_dir_all(&dest_spec_dir).unwrap();
1821
1822 let existing_file = dest_spec_dir.join("existing-test.md");
1823 fs::write(&existing_file, "# Already here").unwrap();
1824
1825 let result = move_to_config_dir_at(&dest_spec_dir, &existing_file).unwrap();
1826
1827 assert!(!result.was_moved, "File should not have been moved");
1828 assert!(
1829 existing_file.exists(),
1830 "File should still exist in original location"
1831 );
1832 assert_eq!(
1833 result.dest_path.canonicalize().unwrap(),
1834 existing_file.canonicalize().unwrap(),
1835 "Path should be the original"
1836 );
1837 }
1839
1840 #[test]
1841 fn test_move_to_config_dir_unknown_extension_goes_to_spec() {
1842 let temp_dir = TempDir::new().unwrap();
1843 let source_dir = temp_dir.path().join("source");
1844 let dest_spec_dir = temp_dir.path().join("config").join("spec");
1845 fs::create_dir_all(&source_dir).unwrap();
1846
1847 let source_file = source_dir.join("test-file.txt");
1848 fs::write(&source_file, "Some content").unwrap();
1849
1850 let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
1851
1852 assert!(result.was_moved, "File should have been moved");
1853 assert!(
1854 !source_file.exists(),
1855 "Source file should be deleted after move"
1856 );
1857 assert!(
1858 result.dest_path.parent().unwrap().ends_with("spec"),
1859 "Unknown extensions should default to spec/ directory"
1860 );
1861 }
1863
1864 #[test]
1865 fn test_move_result_struct() {
1866 let result = MoveResult {
1868 dest_path: PathBuf::from("/test/path"),
1869 was_moved: true,
1870 };
1871 assert_eq!(result.dest_path, PathBuf::from("/test/path"));
1872 assert!(result.was_moved);
1873 }
1874
1875 #[test]
1876 fn test_list_projects_empty_when_no_projects() {
1877 let temp_dir = TempDir::new().unwrap();
1878 let config_dir = temp_dir.path().join(".config").join("autom8");
1879 fs::create_dir_all(&config_dir).unwrap();
1880
1881 let projects = list_projects_at(&config_dir).unwrap();
1882 assert!(
1883 projects.is_empty(),
1884 "Should return empty list when no projects exist"
1885 );
1886 }
1887
1888 #[test]
1889 fn test_list_projects_returns_sorted_list() {
1890 let temp_dir = TempDir::new().unwrap();
1891 let config_dir = temp_dir.path().join(".config").join("autom8");
1892
1893 fs::create_dir_all(config_dir.join("zebra")).unwrap();
1895 fs::create_dir_all(config_dir.join("alpha")).unwrap();
1896 fs::create_dir_all(config_dir.join("mango")).unwrap();
1897
1898 let projects = list_projects_at(&config_dir).unwrap();
1899
1900 assert_eq!(projects.len(), 3);
1901 assert_eq!(projects[0], "alpha", "First project should be 'alpha'");
1902 assert_eq!(projects[1], "mango", "Second project should be 'mango'");
1903 assert_eq!(projects[2], "zebra", "Third project should be 'zebra'");
1904 }
1905
1906 #[test]
1907 fn test_list_projects_ignores_files() {
1908 let temp_dir = TempDir::new().unwrap();
1909 let config_dir = temp_dir.path().join(".config").join("autom8");
1910 fs::create_dir_all(&config_dir).unwrap();
1911
1912 fs::create_dir_all(config_dir.join("my-project")).unwrap();
1914 fs::write(config_dir.join("some-file.txt"), "not a project").unwrap();
1915
1916 let projects = list_projects_at(&config_dir).unwrap();
1917
1918 assert_eq!(projects.len(), 1, "Should only include directories");
1919 assert_eq!(projects[0], "my-project");
1920 }
1921
1922 #[test]
1923 fn test_list_projects_empty_when_dir_does_not_exist() {
1924 let temp_dir = TempDir::new().unwrap();
1925 let non_existent_dir = temp_dir.path().join("does-not-exist");
1926
1927 let projects = list_projects_at(&non_existent_dir).unwrap();
1928 assert!(
1929 projects.is_empty(),
1930 "Should return empty list for non-existent directory"
1931 );
1932 }
1933
1934 #[test]
1939 fn test_project_status_needs_attention_with_active_run() {
1940 let status = ProjectStatus {
1941 name: "test-project".to_string(),
1942 has_active_run: true,
1943 run_status: Some(crate::state::RunStatus::Running),
1944 incomplete_spec_count: 0,
1945 total_spec_count: 0,
1946 };
1947 assert!(status.needs_attention(), "Active run should need attention");
1948 assert!(!status.is_idle());
1949 }
1950
1951 #[test]
1952 fn test_project_status_needs_attention_with_failed_run() {
1953 let status = ProjectStatus {
1954 name: "test-project".to_string(),
1955 has_active_run: false,
1956 run_status: Some(crate::state::RunStatus::Failed),
1957 incomplete_spec_count: 0,
1958 total_spec_count: 0,
1959 };
1960 assert!(status.needs_attention(), "Failed run should need attention");
1961 assert!(!status.is_idle());
1962 }
1963
1964 #[test]
1965 fn test_project_status_needs_attention_with_incomplete_specs() {
1966 let status = ProjectStatus {
1967 name: "test-project".to_string(),
1968 has_active_run: false,
1969 run_status: None,
1970 incomplete_spec_count: 2,
1971 total_spec_count: 3,
1972 };
1973 assert!(
1974 status.needs_attention(),
1975 "Incomplete specs should need attention"
1976 );
1977 assert!(!status.is_idle());
1978 }
1979
1980 #[test]
1981 fn test_project_status_idle_when_no_work() {
1982 let status = ProjectStatus {
1983 name: "test-project".to_string(),
1984 has_active_run: false,
1985 run_status: Some(crate::state::RunStatus::Completed),
1986 incomplete_spec_count: 0,
1987 total_spec_count: 1,
1988 };
1989 assert!(
1990 !status.needs_attention(),
1991 "Completed project should not need attention"
1992 );
1993 assert!(status.is_idle());
1994 }
1995
1996 #[test]
1997 fn test_project_status_idle_when_no_runs_no_specs() {
1998 let status = ProjectStatus {
1999 name: "test-project".to_string(),
2000 has_active_run: false,
2001 run_status: None,
2002 incomplete_spec_count: 0,
2003 total_spec_count: 0,
2004 };
2005 assert!(!status.needs_attention());
2006 assert!(status.is_idle());
2007 }
2008
2009 #[test]
2010 fn test_global_status_empty_when_no_projects() {
2011 let temp_dir = TempDir::new().unwrap();
2012 let config_dir = temp_dir.path().join(".config").join("autom8");
2013 fs::create_dir_all(&config_dir).unwrap();
2014
2015 let statuses = global_status_at(&config_dir).unwrap();
2016 assert!(
2017 statuses.is_empty(),
2018 "Should return empty list when no projects exist"
2019 );
2020 }
2021
2022 #[test]
2023 fn test_global_status_returns_all_projects() {
2024 let temp_dir = TempDir::new().unwrap();
2025 let config_dir = temp_dir.path().join(".config").join("autom8");
2026
2027 fs::create_dir_all(config_dir.join("project-a").join("spec")).unwrap();
2029 fs::create_dir_all(config_dir.join("project-b").join("spec")).unwrap();
2030
2031 let statuses = global_status_at(&config_dir).unwrap();
2032
2033 assert_eq!(statuses.len(), 2);
2034 assert_eq!(statuses[0].name, "project-a");
2035 assert_eq!(statuses[1].name, "project-b");
2036 }
2037
2038 #[test]
2039 fn test_global_status_detects_active_run() {
2040 use crate::state::{RunState, StateManager};
2041
2042 let temp_dir = TempDir::new().unwrap();
2043 let config_dir = temp_dir.path().join(".config").join("autom8");
2044 let project_dir = config_dir.join("active-project");
2045 fs::create_dir_all(project_dir.join("spec")).unwrap();
2046
2047 let sm = StateManager::with_dir(project_dir);
2049 let run_state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
2050 sm.save(&run_state).unwrap();
2051
2052 let statuses = global_status_at(&config_dir).unwrap();
2053
2054 assert_eq!(statuses.len(), 1);
2055 assert!(statuses[0].has_active_run);
2056 assert_eq!(
2057 statuses[0].run_status,
2058 Some(crate::state::RunStatus::Running)
2059 );
2060 }
2061
2062 #[test]
2063 fn test_global_status_counts_incomplete_specs() {
2064 let temp_dir = TempDir::new().unwrap();
2065 let config_dir = temp_dir.path().join(".config").join("autom8");
2066 let project_dir = config_dir.join("spec-project");
2067 let spec_dir = project_dir.join("spec");
2068 fs::create_dir_all(&spec_dir).unwrap();
2069
2070 let incomplete_prd = r#"{
2072 "project": "Test Project",
2073 "branchName": "test",
2074 "description": "Test",
2075 "userStories": [
2076 {"id": "US-001", "title": "Story 1", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": false}
2077 ]
2078 }"#;
2079 fs::write(spec_dir.join("spec-test.json"), incomplete_prd).unwrap();
2080
2081 let complete_prd = r#"{
2083 "project": "Complete Project",
2084 "branchName": "test",
2085 "description": "Test",
2086 "userStories": [
2087 {"id": "US-001", "title": "Story 1", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": true}
2088 ]
2089 }"#;
2090 fs::write(spec_dir.join("spec-complete.json"), complete_prd).unwrap();
2091
2092 let statuses = global_status_at(&config_dir).unwrap();
2093
2094 assert_eq!(statuses.len(), 1);
2095 assert_eq!(statuses[0].incomplete_spec_count, 1);
2096 assert_eq!(statuses[0].total_spec_count, 2);
2097 }
2098
2099 #[test]
2104 fn test_project_tree_info_status_label_running() {
2105 let info = ProjectTreeInfo {
2106 name: "test".to_string(),
2107 has_active_run: true,
2108 run_status: Some(crate::state::RunStatus::Running),
2109 spec_count: 1,
2110 incomplete_spec_count: 0,
2111 spec_md_count: 0,
2112 runs_count: 0,
2113 last_run_date: None,
2114 };
2115 assert_eq!(info.status_label(), "running");
2116 }
2117
2118 #[test]
2119 fn test_project_tree_info_status_label_failed() {
2120 let info = ProjectTreeInfo {
2121 name: "test".to_string(),
2122 has_active_run: false,
2123 run_status: Some(crate::state::RunStatus::Failed),
2124 spec_count: 1,
2125 incomplete_spec_count: 0,
2126 spec_md_count: 0,
2127 runs_count: 0,
2128 last_run_date: None,
2129 };
2130 assert_eq!(info.status_label(), "failed");
2131 }
2132
2133 #[test]
2134 fn test_project_tree_info_status_label_incomplete() {
2135 let info = ProjectTreeInfo {
2136 name: "test".to_string(),
2137 has_active_run: false,
2138 run_status: None,
2139 spec_count: 2,
2140 incomplete_spec_count: 1,
2141 spec_md_count: 0,
2142 runs_count: 0,
2143 last_run_date: None,
2144 };
2145 assert_eq!(info.status_label(), "incomplete");
2146 }
2147
2148 #[test]
2149 fn test_project_tree_info_status_label_complete() {
2150 let info = ProjectTreeInfo {
2151 name: "test".to_string(),
2152 has_active_run: false,
2153 run_status: None,
2154 spec_count: 2,
2155 incomplete_spec_count: 0,
2156 spec_md_count: 1,
2157 runs_count: 0,
2158 last_run_date: None,
2159 };
2160 assert_eq!(info.status_label(), "complete");
2161 }
2162
2163 #[test]
2164 fn test_project_tree_info_status_label_empty() {
2165 let info = ProjectTreeInfo {
2166 name: "test".to_string(),
2167 has_active_run: false,
2168 run_status: None,
2169 spec_count: 0,
2170 incomplete_spec_count: 0,
2171 spec_md_count: 0,
2172 runs_count: 0,
2173 last_run_date: None,
2174 };
2175 assert_eq!(info.status_label(), "empty");
2176 }
2177
2178 #[test]
2179 fn test_project_tree_info_has_content_true() {
2180 let info = ProjectTreeInfo {
2181 name: "test".to_string(),
2182 has_active_run: false,
2183 run_status: None,
2184 spec_count: 1,
2185 incomplete_spec_count: 0,
2186 spec_md_count: 0,
2187 runs_count: 0,
2188 last_run_date: None,
2189 };
2190 assert!(info.has_content());
2191 }
2192
2193 #[test]
2194 fn test_project_tree_info_has_content_false() {
2195 let info = ProjectTreeInfo {
2196 name: "test".to_string(),
2197 has_active_run: false,
2198 run_status: None,
2199 spec_count: 0,
2200 incomplete_spec_count: 0,
2201 spec_md_count: 0,
2202 runs_count: 0,
2203 last_run_date: None,
2204 };
2205 assert!(!info.has_content());
2206 }
2207
2208 #[test]
2209 fn test_project_tree_info_has_content_with_active_run() {
2210 let info = ProjectTreeInfo {
2211 name: "test".to_string(),
2212 has_active_run: true,
2213 run_status: Some(crate::state::RunStatus::Running),
2214 spec_count: 0,
2215 incomplete_spec_count: 0,
2216 spec_md_count: 0,
2217 runs_count: 0,
2218 last_run_date: None,
2219 };
2220 assert!(info.has_content());
2221 }
2222
2223 #[test]
2228 fn test_us008_project_exists_false_for_nonexistent() {
2229 let result = project_exists("nonexistent-project-xyz-12345");
2230 assert!(result.is_ok());
2231 assert!(!result.unwrap(), "nonexistent project should return false");
2232 }
2233
2234 #[test]
2235 fn test_us008_get_project_description_nonexistent_project() {
2236 let result = get_project_description("nonexistent-project-xyz-12345");
2238 assert!(result.is_ok());
2239 assert!(
2240 result.unwrap().is_none(),
2241 "nonexistent project should return None"
2242 );
2243 }
2244
2245 #[test]
2246 fn test_us008_spec_summary_struct_fields() {
2247 let summary = SpecSummary {
2249 filename: "test.json".to_string(),
2250 path: PathBuf::from("/test"),
2251 project_name: "Test Project".to_string(),
2252 branch_name: "feature/test".to_string(),
2253 description: "Test description".to_string(),
2254 stories: vec![StorySummary {
2255 id: "US-001".to_string(),
2256 title: "Test Story".to_string(),
2257 passes: true,
2258 }],
2259 completed_count: 1,
2260 total_count: 1,
2261 is_active: false,
2262 };
2263
2264 assert_eq!(summary.filename, "test.json");
2265 assert_eq!(summary.project_name, "Test Project");
2266 assert_eq!(summary.branch_name, "feature/test");
2267 assert_eq!(summary.completed_count, 1);
2268 assert_eq!(summary.total_count, 1);
2269 assert!(!summary.is_active);
2270 }
2271
2272 #[test]
2273 fn test_us008_story_summary_struct_fields() {
2274 let story = StorySummary {
2276 id: "US-001".to_string(),
2277 title: "Test Story".to_string(),
2278 passes: false,
2279 };
2280
2281 assert_eq!(story.id, "US-001");
2282 assert_eq!(story.title, "Test Story");
2283 assert!(!story.passes);
2284 }
2285
2286 #[test]
2291 fn test_config_default_all_true() {
2292 let config = Config::default();
2293 assert!(config.review, "review should default to true");
2294 assert!(config.commit, "commit should default to true");
2295 assert!(config.pull_request, "pull_request should default to true");
2296 assert!(config.worktree, "worktree should default to true");
2297 }
2298
2299 #[test]
2300 fn test_config_serialize_to_toml() {
2301 let config = Config::default();
2302 let toml_str = toml::to_string(&config).unwrap();
2303
2304 assert!(toml_str.contains("review = true"));
2305 assert!(toml_str.contains("commit = true"));
2306 assert!(toml_str.contains("pull_request = true"));
2307 assert!(toml_str.contains("worktree = true"));
2308 }
2309
2310 #[test]
2311 fn test_config_deserialize_from_toml() {
2312 let toml_str = r#"
2313 review = false
2314 commit = true
2315 pull_request = false
2316 worktree = true
2317 "#;
2318
2319 let config: Config = toml::from_str(toml_str).unwrap();
2320
2321 assert!(!config.review);
2322 assert!(config.commit);
2323 assert!(!config.pull_request);
2324 assert!(config.worktree);
2325 }
2326
2327 #[test]
2328 fn test_config_deserialize_partial_toml_uses_defaults() {
2329 let toml_str = r#"
2331 commit = false
2332 "#;
2333
2334 let config: Config = toml::from_str(toml_str).unwrap();
2335
2336 assert!(config.review, "missing review should default to true");
2337 assert!(!config.commit, "commit should be false as specified");
2338 assert!(
2339 config.pull_request,
2340 "missing pull_request should default to true"
2341 );
2342 assert!(config.worktree, "missing worktree should default to true");
2343 }
2344
2345 #[test]
2346 fn test_config_deserialize_empty_toml_uses_all_defaults() {
2347 let toml_str = "";
2348
2349 let config: Config = toml::from_str(toml_str).unwrap();
2350
2351 assert!(config.review);
2352 assert!(config.commit);
2353 assert!(config.pull_request);
2354 assert!(config.worktree);
2355 }
2356
2357 #[test]
2358 fn test_config_roundtrip() {
2359 let original = Config {
2360 review: false,
2361 commit: true,
2362 pull_request: false,
2363 worktree: true,
2364 ..Default::default()
2365 };
2366
2367 let toml_str = toml::to_string(&original).unwrap();
2368 let deserialized: Config = toml::from_str(&toml_str).unwrap();
2369
2370 assert_eq!(original, deserialized);
2371 }
2372
2373 #[test]
2374 fn test_config_equality() {
2375 let config1 = Config::default();
2376 let config2 = Config::default();
2377 assert_eq!(config1, config2);
2378
2379 let config3 = Config {
2380 review: false,
2381 ..Default::default()
2382 };
2383 assert_ne!(config1, config3);
2384 }
2385
2386 #[test]
2387 fn test_config_clone() {
2388 let original = Config {
2389 review: false,
2390 commit: true,
2391 pull_request: false,
2392 worktree: true,
2393 ..Default::default()
2394 };
2395
2396 let cloned = original.clone();
2397 assert_eq!(original, cloned);
2398 }
2399
2400 #[test]
2401 fn test_config_debug_format() {
2402 let config = Config::default();
2403 let debug_str = format!("{:?}", config);
2404
2405 assert!(debug_str.contains("Config"));
2406 assert!(debug_str.contains("review"));
2407 assert!(debug_str.contains("commit"));
2408 assert!(debug_str.contains("pull_request"));
2409 assert!(debug_str.contains("worktree"));
2410 }
2411
2412 #[test]
2417 fn test_generate_config_with_comments_includes_all_fields() {
2418 let config = Config::default();
2419 let content = generate_config_with_comments(&config);
2420
2421 assert!(content.contains("review = true"));
2423 assert!(content.contains("commit = true"));
2424 assert!(content.contains("pull_request = true"));
2425 assert!(content.contains("worktree = true"));
2426 }
2427
2428 #[test]
2429 fn test_generate_config_with_comments_has_explanatory_comments() {
2430 let config = Config::default();
2431 let content = generate_config_with_comments(&config);
2432
2433 assert!(content.contains("# Review state"));
2435 assert!(content.contains("# Commit state"));
2436 assert!(content.contains("# Pull request state"));
2437 assert!(content.contains("# Worktree mode"));
2438
2439 assert!(content.contains("- true:"));
2441 assert!(content.contains("- false:"));
2442 }
2443
2444 #[test]
2445 fn test_generate_config_with_comments_preserves_custom_values() {
2446 let config = Config {
2447 review: false,
2448 commit: true,
2449 pull_request: false,
2450 worktree: true,
2451 ..Default::default()
2452 };
2453 let content = generate_config_with_comments(&config);
2454
2455 assert!(content.contains("review = false"));
2456 assert!(content.contains("commit = true"));
2457 assert!(content.contains("pull_request = false"));
2458 assert!(content.contains("worktree = true"));
2459 }
2460
2461 #[test]
2462 fn test_default_config_with_comments_is_valid_toml() {
2463 let config: Config = toml::from_str(DEFAULT_CONFIG_WITH_COMMENTS).unwrap();
2465
2466 assert!(config.review);
2467 assert!(config.commit);
2468 assert!(config.pull_request);
2469 assert!(config.worktree);
2470 }
2471
2472 #[test]
2473 fn test_load_global_config_creates_file_when_missing() {
2474 let temp_dir = TempDir::new().unwrap();
2475 let config_dir = temp_dir.path().join(".config").join("autom8");
2476 fs::create_dir_all(&config_dir).unwrap();
2477
2478 let config_path = config_dir.join("config.toml");
2479 assert!(
2480 !config_path.exists(),
2481 "Config file should not exist initially"
2482 );
2483
2484 let content = DEFAULT_CONFIG_WITH_COMMENTS;
2487 fs::write(&config_path, content).unwrap();
2488
2489 let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
2490 assert_eq!(loaded, Config::default());
2491 }
2492
2493 #[test]
2494 fn test_save_and_load_global_config_roundtrip() {
2495 let temp_dir = TempDir::new().unwrap();
2496 let config_dir = temp_dir.path().join(".config").join("autom8");
2497 fs::create_dir_all(&config_dir).unwrap();
2498
2499 let config_path = config_dir.join("config.toml");
2500
2501 let custom_config = Config {
2503 review: false,
2504 commit: true,
2505 pull_request: false,
2506 ..Default::default()
2507 };
2508
2509 let content = generate_config_with_comments(&custom_config);
2511 fs::write(&config_path, content).unwrap();
2512
2513 let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
2515
2516 assert_eq!(loaded, custom_config);
2517 }
2518
2519 #[test]
2520 fn test_load_global_config_handles_partial_config() {
2521 let temp_dir = TempDir::new().unwrap();
2522 let config_dir = temp_dir.path().join(".config").join("autom8");
2523 fs::create_dir_all(&config_dir).unwrap();
2524
2525 let config_path = config_dir.join("config.toml");
2526
2527 let partial_content = r#"
2529# Partial config
2530review = false
2531commit = true
2532"#;
2533 fs::write(&config_path, partial_content).unwrap();
2534
2535 let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
2537
2538 assert!(!loaded.review);
2539 assert!(loaded.commit);
2540 assert!(
2541 loaded.pull_request,
2542 "Missing pull_request should default to true"
2543 );
2544 }
2545
2546 #[test]
2547 fn test_generated_config_includes_note_about_pr_requiring_commit() {
2548 let config = Config::default();
2549 let content = generate_config_with_comments(&config);
2550
2551 assert!(
2553 content.contains("Requires commit = true"),
2554 "Config should note that PR requires commit"
2555 );
2556 }
2557
2558 #[test]
2559 fn test_global_config_file_has_comments_after_save() {
2560 let temp_dir = TempDir::new().unwrap();
2561 let config_dir = temp_dir.path().join(".config").join("autom8");
2562 fs::create_dir_all(&config_dir).unwrap();
2563
2564 let config_path = config_dir.join("config.toml");
2565
2566 let config = Config::default();
2568 let content = generate_config_with_comments(&config);
2569 fs::write(&config_path, content).unwrap();
2570
2571 let raw_content = fs::read_to_string(&config_path).unwrap();
2573 assert!(
2574 raw_content.contains("#"),
2575 "Config file should contain comments"
2576 );
2577 assert!(
2578 raw_content.contains("# Autom8 Configuration"),
2579 "Config file should have header comment"
2580 );
2581 }
2582
2583 #[test]
2588 fn test_us003_project_config_path_for_returns_correct_path() {
2589 let path = project_config_path_for("my-test-project").unwrap();
2590 assert!(path.ends_with("config.toml"));
2591 assert!(path.parent().unwrap().ends_with("my-test-project"));
2592 }
2593
2594 #[test]
2595 fn test_us003_load_project_config_creates_from_global_when_missing() {
2596 let temp_dir = TempDir::new().unwrap();
2598 let config_dir = temp_dir.path().join(".config").join("autom8");
2599 fs::create_dir_all(&config_dir).unwrap();
2600
2601 let global_config = Config {
2603 review: true,
2604 commit: false,
2605 pull_request: false,
2606 ..Default::default()
2607 };
2608 let global_path = config_dir.join("config.toml");
2609 let global_content = generate_config_with_comments(&global_config);
2610 fs::write(&global_path, &global_content).unwrap();
2611
2612 let project_dir = config_dir.join("test-project");
2614 fs::create_dir_all(project_dir.join("spec")).unwrap();
2615 fs::create_dir_all(project_dir.join("runs")).unwrap();
2616
2617 let project_config_path = project_dir.join("config.toml");
2618 assert!(
2619 !project_config_path.exists(),
2620 "Project config should not exist initially"
2621 );
2622
2623 fs::write(&project_config_path, &global_content).unwrap();
2626
2627 assert!(
2629 project_config_path.exists(),
2630 "Project config should be created when missing"
2631 );
2632
2633 let loaded: Config =
2635 toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2636 assert_eq!(
2637 loaded, global_config,
2638 "Project config should match global config"
2639 );
2640 }
2641
2642 #[test]
2643 fn test_us003_load_project_config_preserves_comments() {
2644 let temp_dir = TempDir::new().unwrap();
2646 let config_dir = temp_dir.path().join(".config").join("autom8");
2647 let project_dir = config_dir.join("test-project");
2648 fs::create_dir_all(&project_dir).unwrap();
2649
2650 let global_config = Config::default();
2652 let global_path = config_dir.join("config.toml");
2653 let global_content = generate_config_with_comments(&global_config);
2654 fs::write(&global_path, &global_content).unwrap();
2655
2656 let project_config_path = project_dir.join("config.toml");
2658 assert!(!project_config_path.exists());
2659
2660 fs::write(&project_config_path, &global_content).unwrap();
2662
2663 let raw_content = fs::read_to_string(&project_config_path).unwrap();
2665
2666 assert!(
2667 raw_content.contains("#"),
2668 "Project config should contain comments"
2669 );
2670 assert!(
2671 raw_content.contains("# Autom8 Configuration"),
2672 "Project config should have header comment"
2673 );
2674 assert!(
2675 raw_content.contains("# Review state"),
2676 "Project config should have review state comment"
2677 );
2678 }
2679
2680 #[test]
2681 fn test_us003_save_project_config_creates_file() {
2682 let temp_dir = TempDir::new().unwrap();
2684 let config_dir = temp_dir.path().join(".config").join("autom8");
2685 let project_dir = config_dir.join("test-project");
2686 fs::create_dir_all(project_dir.join("spec")).unwrap();
2687 fs::create_dir_all(project_dir.join("runs")).unwrap();
2688
2689 let config = Config {
2690 review: false,
2691 commit: true,
2692 pull_request: true,
2693 ..Default::default()
2694 };
2695
2696 let project_config_path = project_dir.join("config.toml");
2698 let content = generate_config_with_comments(&config);
2699 fs::write(&project_config_path, &content).unwrap();
2700
2701 assert!(project_config_path.exists());
2703
2704 let loaded: Config =
2705 toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2706 assert_eq!(loaded, config);
2707 }
2708
2709 #[test]
2710 fn test_us003_save_project_config_preserves_comments() {
2711 let temp_dir = TempDir::new().unwrap();
2713 let config_dir = temp_dir.path().join(".config").join("autom8");
2714 let project_dir = config_dir.join("test-project");
2715 fs::create_dir_all(&project_dir).unwrap();
2716
2717 let config = Config::default();
2718 let project_config_path = project_dir.join("config.toml");
2719 let content = generate_config_with_comments(&config);
2720 fs::write(&project_config_path, &content).unwrap();
2721
2722 let raw_content = fs::read_to_string(&project_config_path).unwrap();
2723
2724 assert!(
2725 raw_content.contains("#"),
2726 "Saved config should contain comments"
2727 );
2728 assert!(
2729 raw_content.contains("# Autom8 Configuration"),
2730 "Saved config should have header comment"
2731 );
2732 }
2733
2734 #[test]
2735 fn test_us003_get_effective_config_returns_project_if_exists() {
2736 let temp_dir = TempDir::new().unwrap();
2738 let config_dir = temp_dir.path().join(".config").join("autom8");
2739 fs::create_dir_all(&config_dir).unwrap();
2740
2741 let global_config = Config::default();
2743 let global_path = config_dir.join("config.toml");
2744 fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
2745
2746 let project_config = Config {
2748 review: false,
2749 commit: false,
2750 pull_request: false,
2751 ..Default::default()
2752 };
2753 let project_dir = config_dir.join("test-project");
2754 fs::create_dir_all(&project_dir).unwrap();
2755 let project_path = project_dir.join("config.toml");
2756 fs::write(
2757 &project_path,
2758 generate_config_with_comments(&project_config),
2759 )
2760 .unwrap();
2761
2762 let effective_path = if project_path.exists() {
2764 &project_path
2765 } else {
2766 &global_path
2767 };
2768
2769 let effective: Config =
2770 toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
2771 assert_eq!(
2772 effective, project_config,
2773 "Should return project config when it exists"
2774 );
2775 }
2776
2777 #[test]
2778 fn test_us003_get_effective_config_returns_global_when_project_missing() {
2779 let temp_dir = TempDir::new().unwrap();
2780 let config_dir = temp_dir.path().join(".config").join("autom8");
2781 fs::create_dir_all(&config_dir).unwrap();
2782
2783 let global_config = Config {
2785 review: true,
2786 commit: true,
2787 pull_request: false,
2788 ..Default::default()
2789 };
2790 let global_path = config_dir.join("config.toml");
2791 let content = generate_config_with_comments(&global_config);
2792 fs::write(&global_path, content).unwrap();
2793
2794 let project_dir = config_dir.join("test-project");
2796 fs::create_dir_all(&project_dir).unwrap();
2797
2798 let project_config_path = project_dir.join("config.toml");
2801 assert!(
2802 !project_config_path.exists(),
2803 "Project config should not exist"
2804 );
2805 assert!(global_path.exists(), "Global config should exist");
2806
2807 let loaded: Config = toml::from_str(&fs::read_to_string(&global_path).unwrap()).unwrap();
2809 assert_eq!(loaded, global_config);
2810 }
2811
2812 #[test]
2813 fn test_us003_project_config_takes_precedence_over_global() {
2814 let temp_dir = TempDir::new().unwrap();
2817 let config_dir = temp_dir.path().join(".config").join("autom8");
2818 fs::create_dir_all(&config_dir).unwrap();
2819
2820 let global_config = Config {
2822 review: true,
2823 commit: true,
2824 pull_request: true,
2825 ..Default::default()
2826 };
2827 let global_path = config_dir.join("config.toml");
2828 fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
2829
2830 let project_config = Config {
2832 review: false,
2833 commit: true,
2834 pull_request: false,
2835 ..Default::default()
2836 };
2837 let project_dir = config_dir.join("my-project");
2838 fs::create_dir_all(&project_dir).unwrap();
2839 let project_path = project_dir.join("config.toml");
2840 fs::write(
2841 &project_path,
2842 generate_config_with_comments(&project_config),
2843 )
2844 .unwrap();
2845
2846 let effective_path = if project_path.exists() {
2848 &project_path
2849 } else {
2850 &global_path
2851 };
2852
2853 let effective: Config =
2854 toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
2855 assert_eq!(
2856 effective, project_config,
2857 "Project config should take precedence over global"
2858 );
2859 assert_ne!(
2860 effective, global_config,
2861 "Should not return global config when project config exists"
2862 );
2863 }
2864
2865 #[test]
2866 fn test_us003_get_effective_config_does_not_create_project_config() {
2867 let temp_dir = TempDir::new().unwrap();
2869 let config_dir = temp_dir.path().join(".config").join("autom8");
2870 fs::create_dir_all(&config_dir).unwrap();
2871
2872 let global_config = Config::default();
2874 let global_path = config_dir.join("config.toml");
2875 fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
2876
2877 let project_dir = config_dir.join("test-project");
2879 fs::create_dir_all(&project_dir).unwrap();
2880 let project_config_path = project_dir.join("config.toml");
2881
2882 assert!(
2884 !project_config_path.exists(),
2885 "Project config should not exist before"
2886 );
2887
2888 let effective_path = if project_config_path.exists() {
2890 &project_config_path
2891 } else {
2892 &global_path
2893 };
2894 let _effective: Config =
2895 toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
2896
2897 assert!(
2899 !project_config_path.exists(),
2900 "get_effective_config should NOT create project config"
2901 );
2902 }
2903
2904 #[test]
2905 fn test_us003_project_config_roundtrip() {
2906 let temp_dir = TempDir::new().unwrap();
2908 let config_dir = temp_dir.path().join(".config").join("autom8");
2909 let project_dir = config_dir.join("test-project");
2910 fs::create_dir_all(&project_dir).unwrap();
2911
2912 let original = Config {
2913 review: false,
2914 commit: true,
2915 pull_request: false,
2916 ..Default::default()
2917 };
2918
2919 let project_config_path = project_dir.join("config.toml");
2921 let content = generate_config_with_comments(&original);
2922 fs::write(&project_config_path, &content).unwrap();
2923
2924 let loaded: Config =
2926 toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2927
2928 assert_eq!(original, loaded, "Config should survive save/load cycle");
2929 }
2930
2931 #[test]
2932 fn test_us003_project_config_handles_partial_config() {
2933 let temp_dir = TempDir::new().unwrap();
2935 let config_dir = temp_dir.path().join(".config").join("autom8");
2936 let project_dir = config_dir.join("test-project");
2937 fs::create_dir_all(&project_dir).unwrap();
2938
2939 let project_config_path = project_dir.join("config.toml");
2940
2941 let partial_content = r#"
2943# Partial project config
2944review = false
2945"#;
2946 fs::write(&project_config_path, partial_content).unwrap();
2947
2948 let loaded: Config =
2950 toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2951
2952 assert!(!loaded.review, "review should be false as specified");
2953 assert!(loaded.commit, "missing commit should default to true");
2954 assert!(
2955 loaded.pull_request,
2956 "missing pull_request should default to true"
2957 );
2958 }
2959
2960 #[test]
2961 fn test_us003_inheritance_simulation_with_temp_dirs() {
2962 let temp_dir = TempDir::new().unwrap();
2964 let config_dir = temp_dir.path().join(".config").join("autom8");
2965 fs::create_dir_all(&config_dir).unwrap();
2966
2967 let global_config = Config {
2969 review: true,
2970 commit: false,
2971 pull_request: false,
2972 ..Default::default()
2973 };
2974 let global_content = generate_config_with_comments(&global_config);
2975 let global_path = config_dir.join("config.toml");
2976 fs::write(&global_path, &global_content).unwrap();
2977
2978 let project_dir = config_dir.join("test-project");
2980 fs::create_dir_all(project_dir.join("spec")).unwrap();
2981 fs::create_dir_all(project_dir.join("runs")).unwrap();
2982
2983 let project_config_path = project_dir.join("config.toml");
2985 assert!(!project_config_path.exists());
2986
2987 fs::write(&project_config_path, &global_content).unwrap();
2989
2990 assert!(project_config_path.exists());
2992 let loaded: Config =
2993 toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
2994 assert_eq!(
2995 loaded, global_config,
2996 "Project config should inherit from global"
2997 );
2998
2999 let project_content = fs::read_to_string(&project_config_path).unwrap();
3001 assert!(project_content.contains("# Autom8 Configuration"));
3002 assert!(project_content.contains("# Review state"));
3003 }
3004
3005 #[test]
3006 fn test_us003_project_config_override_simulation() {
3007 let temp_dir = TempDir::new().unwrap();
3009 let config_dir = temp_dir.path().join(".config").join("autom8");
3010 fs::create_dir_all(&config_dir).unwrap();
3011
3012 let global_config = Config {
3014 review: true,
3015 commit: true,
3016 pull_request: true,
3017 ..Default::default()
3018 };
3019 let global_path = config_dir.join("config.toml");
3020 fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
3021
3022 let project_config = Config {
3024 review: false,
3025 commit: true,
3026 pull_request: false,
3027 ..Default::default()
3028 };
3029 let project_dir = config_dir.join("my-project");
3030 fs::create_dir_all(&project_dir).unwrap();
3031 let project_path = project_dir.join("config.toml");
3032 fs::write(
3033 &project_path,
3034 generate_config_with_comments(&project_config),
3035 )
3036 .unwrap();
3037
3038 let effective_path = if project_path.exists() {
3040 &project_path
3041 } else {
3042 &global_path
3043 };
3044
3045 let effective: Config =
3046 toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
3047 assert_eq!(
3048 effective, project_config,
3049 "Project config should take precedence"
3050 );
3051 assert_ne!(effective.review, global_config.review);
3052 assert_ne!(effective.pull_request, global_config.pull_request);
3053 }
3054
3055 #[test]
3060 fn test_us004_validate_config_accepts_default_config() {
3061 let config = Config::default();
3062 assert!(validate_config(&config).is_ok());
3063 }
3064
3065 #[test]
3066 fn test_us004_validate_config_accepts_all_true() {
3067 let config = Config {
3068 review: true,
3069 commit: true,
3070 pull_request: true,
3071 ..Default::default()
3072 };
3073 assert!(validate_config(&config).is_ok());
3074 }
3075
3076 #[test]
3077 fn test_us004_validate_config_accepts_all_false() {
3078 let config = Config {
3079 review: false,
3080 commit: false,
3081 pull_request: false,
3082 ..Default::default()
3083 };
3084 assert!(validate_config(&config).is_ok());
3085 }
3086
3087 #[test]
3088 fn test_us004_validate_config_accepts_commit_true_pr_false() {
3089 let config = Config {
3090 review: true,
3091 commit: true,
3092 pull_request: false,
3093 ..Default::default()
3094 };
3095 assert!(validate_config(&config).is_ok());
3096 }
3097
3098 #[test]
3099 fn test_us004_validate_config_accepts_commit_false_pr_false() {
3100 let config = Config {
3101 review: true,
3102 commit: false,
3103 pull_request: false,
3104 ..Default::default()
3105 };
3106 assert!(validate_config(&config).is_ok());
3107 }
3108
3109 #[test]
3110 fn test_us004_validate_config_rejects_pr_true_commit_false() {
3111 let config = Config {
3112 review: true,
3113 commit: false,
3114 pull_request: true,
3115 ..Default::default()
3116 };
3117 let result = validate_config(&config);
3118 assert!(result.is_err());
3119 assert_eq!(result.unwrap_err(), ConfigError::PullRequestWithoutCommit);
3120 }
3121
3122 #[test]
3123 fn test_us004_config_error_message_is_actionable() {
3124 let error = ConfigError::PullRequestWithoutCommit;
3125 let message = error.to_string();
3126
3127 assert_eq!(
3129 message,
3130 "Cannot create pull request without commits. \
3131 Either set `commit = true` or set `pull_request = false`"
3132 );
3133 }
3134
3135 #[test]
3136 fn test_us004_config_error_implements_error_trait() {
3137 let error = ConfigError::PullRequestWithoutCommit;
3138 let _: &dyn std::error::Error = &error;
3140 }
3141
3142 #[test]
3143 fn test_us004_config_error_debug_format() {
3144 let error = ConfigError::PullRequestWithoutCommit;
3145 let debug_str = format!("{:?}", error);
3146 assert!(debug_str.contains("PullRequestWithoutCommit"));
3147 }
3148
3149 #[test]
3150 fn test_us004_config_error_clone() {
3151 let error = ConfigError::PullRequestWithoutCommit;
3152 let cloned = error.clone();
3153 assert_eq!(error, cloned);
3154 }
3155
3156 #[test]
3157 fn test_us004_validate_config_accepts_review_false_with_valid_pr_commit() {
3158 let config = Config {
3160 review: false,
3161 commit: true,
3162 pull_request: true,
3163 ..Default::default()
3164 };
3165 assert!(validate_config(&config).is_ok());
3166 }
3167
3168 #[test]
3169 fn test_us004_validate_config_all_combinations() {
3170 let combinations = [
3172 (false, false, false, true), (false, false, true, false), (false, true, false, true), (false, true, true, true), (true, false, false, true), (true, false, true, false), (true, true, false, true), (true, true, true, true), ];
3181
3182 for (review, commit, pull_request, should_be_valid) in combinations {
3183 let config = Config {
3184 review,
3185 commit,
3186 pull_request,
3187 ..Default::default()
3188 };
3189 let result = validate_config(&config);
3190 assert_eq!(
3191 result.is_ok(),
3192 should_be_valid,
3193 "Config (review={}, commit={}, pull_request={}) expected valid={}, got valid={}",
3194 review,
3195 commit,
3196 pull_request,
3197 should_be_valid,
3198 result.is_ok()
3199 );
3200 }
3201 }
3202
3203 #[test]
3204 fn test_us004_get_effective_config_validates_before_returning() {
3205 let invalid_config = Config {
3211 review: true,
3212 commit: false,
3213 pull_request: true,
3214 ..Default::default()
3215 };
3216 let validation_result = validate_config(&invalid_config);
3217 assert!(validation_result.is_err());
3218
3219 let error = validation_result.unwrap_err();
3221 let message = error.to_string();
3222 assert!(message.contains("commit = true"));
3223 assert!(message.contains("pull_request = false"));
3224 }
3225
3226 #[test]
3227 fn test_us004_validation_integration_with_autom8_error() {
3228 let config_error = ConfigError::PullRequestWithoutCommit;
3230 let autom8_error = Autom8Error::Config(config_error.to_string());
3231
3232 let error_string = format!("{}", autom8_error);
3234 assert!(error_string.contains("Cannot create pull request without commits"));
3235 }
3236
3237 #[test]
3242 fn test_config_with_use_tui_field_still_parses() {
3243 let toml_str = r#"
3245 review = true
3246 commit = true
3247 pull_request = true
3248 use_tui = true
3249 "#;
3250 let config: Config = toml::from_str(toml_str).unwrap();
3252 assert!(config.review);
3253 assert!(config.commit);
3254 assert!(config.pull_request);
3255 }
3256
3257 #[test]
3262 fn test_worktree_config_defaults_to_true() {
3263 let config = Config::default();
3264 assert!(config.worktree, "worktree should default to true");
3265 }
3266
3267 #[test]
3268 fn test_worktree_config_can_be_enabled() {
3269 let toml_str = r#"
3270 worktree = true
3271 "#;
3272 let config: Config = toml::from_str(toml_str).unwrap();
3273 assert!(
3274 config.worktree,
3275 "worktree should be true when set in config"
3276 );
3277 }
3278
3279 #[test]
3280 fn test_worktree_config_missing_defaults_to_true() {
3281 let toml_str = r#"
3283 review = true
3284 commit = true
3285 pull_request = true
3286 "#;
3287 let config: Config = toml::from_str(toml_str).unwrap();
3288 assert!(
3289 config.worktree,
3290 "missing worktree field should default to true"
3291 );
3292 }
3293
3294 #[test]
3295 fn test_worktree_config_explicit_false() {
3296 let toml_str = r#"
3297 worktree = false
3298 "#;
3299 let config: Config = toml::from_str(toml_str).unwrap();
3300 assert!(
3301 !config.worktree,
3302 "explicit worktree = false should be respected"
3303 );
3304 }
3305
3306 #[test]
3307 fn test_worktree_config_with_all_other_fields() {
3308 let toml_str = r#"
3309 review = false
3310 commit = true
3311 pull_request = false
3312 worktree = true
3313 "#;
3314 let config: Config = toml::from_str(toml_str).unwrap();
3315 assert!(!config.review);
3316 assert!(config.commit);
3317 assert!(!config.pull_request);
3318 assert!(config.worktree);
3319 }
3320
3321 #[test]
3322 fn test_worktree_config_documentation_note_in_generated_comments() {
3323 let config = Config::default();
3324 let content = generate_config_with_comments(&config);
3325
3326 assert!(
3328 content.contains("Requires a git repository"),
3329 "config comments should document git repo requirement"
3330 );
3331 }
3332
3333 #[test]
3338 fn test_worktree_cleanup_config_defaults_to_false() {
3339 let config = Config::default();
3340 assert!(
3341 !config.worktree_cleanup,
3342 "worktree_cleanup should default to false for backward compatibility"
3343 );
3344 }
3345
3346 #[test]
3347 fn test_worktree_cleanup_config_can_be_enabled() {
3348 let toml_str = r#"
3349 worktree_cleanup = true
3350 "#;
3351 let config: Config = toml::from_str(toml_str).unwrap();
3352 assert!(
3353 config.worktree_cleanup,
3354 "worktree_cleanup should be true when set in config"
3355 );
3356 }
3357
3358 #[test]
3359 fn test_worktree_cleanup_config_missing_defaults_to_false() {
3360 let toml_str = r#"
3362 review = true
3363 commit = true
3364 worktree = true
3365 "#;
3366 let config: Config = toml::from_str(toml_str).unwrap();
3367 assert!(
3368 !config.worktree_cleanup,
3369 "missing worktree_cleanup field should default to false"
3370 );
3371 }
3372
3373 #[test]
3374 fn test_worktree_cleanup_config_explicit_false() {
3375 let toml_str = r#"
3376 worktree_cleanup = false
3377 "#;
3378 let config: Config = toml::from_str(toml_str).unwrap();
3379 assert!(
3380 !config.worktree_cleanup,
3381 "explicit worktree_cleanup = false should be respected"
3382 );
3383 }
3384
3385 #[test]
3386 fn test_worktree_cleanup_config_with_all_worktree_fields() {
3387 let toml_str = r#"
3388 worktree = true
3389 worktree_path_pattern = "{repo}-test-{branch}"
3390 worktree_cleanup = true
3391 "#;
3392 let config: Config = toml::from_str(toml_str).unwrap();
3393 assert!(config.worktree);
3394 assert_eq!(config.worktree_path_pattern, "{repo}-test-{branch}");
3395 assert!(config.worktree_cleanup);
3396 }
3397
3398 #[test]
3399 fn test_worktree_cleanup_in_generated_comments() {
3400 let config = Config {
3401 worktree_cleanup: true,
3402 ..Default::default()
3403 };
3404 let content = generate_config_with_comments(&config);
3405
3406 assert!(
3408 content.contains("worktree_cleanup = true"),
3409 "generated config should include worktree_cleanup setting"
3410 );
3411 assert!(
3412 content.contains("successful completion"),
3413 "config comments should document cleanup behavior"
3414 );
3415 }
3416
3417 #[test]
3418 fn test_worktree_cleanup_in_default_config_with_comments() {
3419 assert!(
3421 DEFAULT_CONFIG_WITH_COMMENTS.contains("worktree_cleanup"),
3422 "DEFAULT_CONFIG_WITH_COMMENTS should include worktree_cleanup"
3423 );
3424 assert!(
3425 DEFAULT_CONFIG_WITH_COMMENTS.contains("worktree_cleanup = false"),
3426 "DEFAULT_CONFIG_WITH_COMMENTS should have worktree_cleanup = false"
3427 );
3428 }
3429
3430 #[test]
3431 fn test_worktree_cleanup_serialization_roundtrip() {
3432 let config = Config {
3433 worktree: true,
3434 worktree_cleanup: true,
3435 ..Default::default()
3436 };
3437
3438 let toml_str = toml::to_string(&config).unwrap();
3440 assert!(toml_str.contains("worktree_cleanup = true"));
3441
3442 let parsed: Config = toml::from_str(&toml_str).unwrap();
3444 assert_eq!(parsed.worktree_cleanup, config.worktree_cleanup);
3445 }
3446
3447 #[test]
3452 fn test_pull_request_draft_config_defaults_to_false() {
3453 let config = Config::default();
3454 assert!(
3455 !config.pull_request_draft,
3456 "pull_request_draft should default to false for backward compatibility"
3457 );
3458 }
3459
3460 #[test]
3461 fn test_pull_request_draft_config_can_be_enabled() {
3462 let toml_str = r#"
3463 pull_request_draft = true
3464 "#;
3465 let config: Config = toml::from_str(toml_str).unwrap();
3466 assert!(
3467 config.pull_request_draft,
3468 "pull_request_draft should be true when set in config"
3469 );
3470 }
3471
3472 #[test]
3473 fn test_pull_request_draft_config_missing_defaults_to_false() {
3474 let toml_str = r#"
3476 review = true
3477 commit = true
3478 pull_request = true
3479 "#;
3480 let config: Config = toml::from_str(toml_str).unwrap();
3481 assert!(
3482 !config.pull_request_draft,
3483 "missing pull_request_draft field should default to false"
3484 );
3485 }
3486
3487 #[test]
3488 fn test_pull_request_draft_config_explicit_false() {
3489 let toml_str = r#"
3490 pull_request_draft = false
3491 "#;
3492 let config: Config = toml::from_str(toml_str).unwrap();
3493 assert!(
3494 !config.pull_request_draft,
3495 "explicit pull_request_draft = false should be respected"
3496 );
3497 }
3498
3499 #[test]
3500 fn test_pull_request_draft_config_with_all_pr_fields() {
3501 let toml_str = r#"
3502 pull_request = true
3503 pull_request_draft = true
3504 "#;
3505 let config: Config = toml::from_str(toml_str).unwrap();
3506 assert!(config.pull_request);
3507 assert!(config.pull_request_draft);
3508 }
3509
3510 #[test]
3511 fn test_pull_request_draft_in_generated_comments() {
3512 let config = Config {
3513 pull_request_draft: true,
3514 ..Default::default()
3515 };
3516 let content = generate_config_with_comments(&config);
3517
3518 assert!(
3520 content.contains("pull_request_draft = true"),
3521 "generated config should include pull_request_draft setting"
3522 );
3523 assert!(
3524 content.contains("draft mode"),
3525 "config comments should document draft mode behavior"
3526 );
3527 }
3528
3529 #[test]
3530 fn test_pull_request_draft_in_default_config_with_comments() {
3531 assert!(
3533 DEFAULT_CONFIG_WITH_COMMENTS.contains("pull_request_draft"),
3534 "DEFAULT_CONFIG_WITH_COMMENTS should include pull_request_draft"
3535 );
3536 assert!(
3537 DEFAULT_CONFIG_WITH_COMMENTS.contains("pull_request_draft = false"),
3538 "DEFAULT_CONFIG_WITH_COMMENTS should have pull_request_draft = false"
3539 );
3540 }
3541
3542 #[test]
3543 fn test_pull_request_draft_serialization_roundtrip() {
3544 let config = Config {
3545 pull_request: true,
3546 pull_request_draft: true,
3547 ..Default::default()
3548 };
3549
3550 let toml_str = toml::to_string(&config).unwrap();
3552 assert!(toml_str.contains("pull_request_draft = true"));
3553
3554 let parsed: Config = toml::from_str(&toml_str).unwrap();
3556 assert_eq!(parsed.pull_request_draft, config.pull_request_draft);
3557 }
3558
3559 #[test]
3562 fn test_us001_spec_summary_is_active_field() {
3563 let spec_active = SpecSummary {
3565 filename: "spec-active.json".to_string(),
3566 path: PathBuf::from("/test/spec-active.json"),
3567 project_name: "test".to_string(),
3568 branch_name: "feature/active".to_string(),
3569 description: "Active spec".to_string(),
3570 stories: vec![],
3571 completed_count: 0,
3572 total_count: 0,
3573 is_active: true,
3574 };
3575
3576 let spec_inactive = SpecSummary {
3577 filename: "spec-inactive.json".to_string(),
3578 path: PathBuf::from("/test/spec-inactive.json"),
3579 project_name: "test".to_string(),
3580 branch_name: "feature/inactive".to_string(),
3581 description: "Inactive spec".to_string(),
3582 stories: vec![],
3583 completed_count: 0,
3584 total_count: 0,
3585 is_active: false,
3586 };
3587
3588 assert!(spec_active.is_active);
3589 assert!(!spec_inactive.is_active);
3590 }
3591
3592 fn compute_last_run_timestamp(
3599 has_active_run: bool,
3600 run_started_at: Option<chrono::DateTime<chrono::Utc>>,
3601 run_finished_at: Option<chrono::DateTime<chrono::Utc>>,
3602 archived_started_at: Option<chrono::DateTime<chrono::Utc>>,
3603 archived_finished_at: Option<chrono::DateTime<chrono::Utc>>,
3604 ) -> Option<chrono::DateTime<chrono::Utc>> {
3605 if has_active_run {
3606 run_started_at
3608 } else {
3609 run_finished_at
3611 .or(run_started_at)
3612 .or(archived_finished_at)
3613 .or(archived_started_at)
3614 }
3615 }
3616
3617 #[test]
3618 fn test_us004_last_run_date_active_run_uses_started_at() {
3619 use chrono::{Duration, Utc};
3620
3621 let started_at = Utc::now() - Duration::minutes(30);
3622 let finished_at = None; let result = compute_last_run_timestamp(
3625 true, Some(started_at), finished_at, None, None, );
3631
3632 assert_eq!(result, Some(started_at));
3633 }
3634
3635 #[test]
3636 fn test_us004_last_run_date_completed_run_uses_finished_at() {
3637 use chrono::{Duration, Utc};
3638
3639 let started_at = Utc::now() - Duration::hours(2);
3640 let finished_at = Utc::now() - Duration::minutes(30);
3641
3642 let result = compute_last_run_timestamp(
3643 false, Some(started_at), Some(finished_at), None, None, );
3649
3650 assert_eq!(result, Some(finished_at));
3652 }
3653
3654 #[test]
3655 fn test_us004_last_run_date_completed_run_fallback_to_started_at() {
3656 use chrono::{Duration, Utc};
3657
3658 let started_at = Utc::now() - Duration::hours(2);
3659 let result = compute_last_run_timestamp(
3662 false, Some(started_at), None, None, None, );
3668
3669 assert_eq!(result, Some(started_at));
3671 }
3672
3673 #[test]
3674 fn test_us004_last_run_date_archived_run_uses_finished_at() {
3675 use chrono::{Duration, Utc};
3676
3677 let archived_started_at = Utc::now() - Duration::days(1);
3678 let archived_finished_at = Utc::now() - Duration::hours(23);
3679
3680 let result = compute_last_run_timestamp(
3681 false, None, None, Some(archived_started_at), Some(archived_finished_at), );
3687
3688 assert_eq!(result, Some(archived_finished_at));
3690 }
3691
3692 #[test]
3693 fn test_us004_last_run_date_no_runs_returns_none() {
3694 let result = compute_last_run_timestamp(
3695 false, None, None, None, None, );
3701
3702 assert_eq!(result, None);
3703 }
3704
3705 #[test]
3706 fn test_us004_last_run_date_prefers_current_over_archived() {
3707 use chrono::{Duration, Utc};
3708
3709 let current_started_at = Utc::now() - Duration::hours(1);
3711 let current_finished_at = Utc::now() - Duration::minutes(30);
3712
3713 let archived_started_at = Utc::now() - Duration::days(7);
3715 let archived_finished_at = Utc::now() - Duration::days(7) + Duration::hours(2);
3716
3717 let result = compute_last_run_timestamp(
3718 false, Some(current_started_at), Some(current_finished_at), Some(archived_started_at), Some(archived_finished_at), );
3724
3725 assert_eq!(result, Some(current_finished_at));
3727 }
3728}