use crate::error::{Autom8Error, Result};
use serde::{Deserialize, Serialize};
use std::env;
use std::fs;
use std::path::PathBuf;
const CONFIG_DIR_NAME: &str = "autom8";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
#[serde(default = "default_true")]
pub review: bool,
#[serde(default = "default_true")]
pub commit: bool,
#[serde(default = "default_true")]
pub pull_request: bool,
#[serde(default = "default_false")]
pub pull_request_draft: bool,
#[serde(default = "default_true")]
pub worktree: bool,
#[serde(default = "default_worktree_path_pattern")]
pub worktree_path_pattern: String,
#[serde(default = "default_false")]
pub worktree_cleanup: bool,
}
fn default_worktree_path_pattern() -> String {
"{repo}-wt-{branch}".to_string()
}
fn default_true() -> bool {
true
}
fn default_false() -> bool {
false
}
impl Default for Config {
fn default() -> Self {
Self {
review: true,
commit: true,
pull_request: true,
pull_request_draft: false,
worktree: true,
worktree_path_pattern: default_worktree_path_pattern(),
worktree_cleanup: false,
}
}
}
use std::error::Error;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ConfigError {
PullRequestWithoutCommit,
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ConfigError::PullRequestWithoutCommit => {
write!(
f,
"Cannot create pull request without commits. \
Either set `commit = true` or set `pull_request = false`"
)
}
}
}
}
impl Error for ConfigError {}
pub fn validate_config(config: &Config) -> std::result::Result<(), ConfigError> {
if config.pull_request && !config.commit {
return Err(ConfigError::PullRequestWithoutCommit);
}
Ok(())
}
const GLOBAL_CONFIG_FILENAME: &str = "config.toml";
const DEFAULT_CONFIG_WITH_COMMENTS: &str = r#"# Autom8 Configuration
# This file controls which states in the autom8 state machine are executed.
# Review state: Code review before committing
# - true: Run code review step to check implementation quality
# - false: Skip code review and proceed directly to commit
review = true
# Commit state: Creating git commits
# - true: Automatically commit changes after implementation
# - false: Leave changes uncommitted (manual commit required)
commit = true
# Pull request state: Creating pull requests
# - true: Automatically create a PR after committing
# - false: Skip PR creation (commits remain on local branch)
# Note: Requires commit = true to work
pull_request = true
# Pull request draft mode: Create PRs as drafts
# - true: Create PRs as drafts (not ready for review)
# - false: Create PRs as regular (ready for review) PRs (default)
# Note: Only applies when pull_request = true. Has no effect otherwise.
pull_request_draft = false
# Worktree mode: Automatic worktree creation for parallel runs
# - true: Create a dedicated worktree for each run (enables parallel sessions, default)
# - false: Run on the current branch (single session per project)
# Note: Requires a git repository. Has no effect outside of git repos.
worktree = true
# Worktree path pattern: Pattern for naming worktree directories
# Placeholders: {repo} = repository name, {branch} = branch name (slugified)
# Default: {repo}-wt-{branch} (e.g., "myproject-wt-feature-login")
worktree_path_pattern = "{repo}-wt-{branch}"
# Worktree cleanup: Automatically remove worktrees after successful completion
# - true: Remove worktree directory after run completes successfully
# - false: Preserve worktrees for manual inspection/cleanup (default)
# Note: Failed runs always keep their worktrees. Only applies when worktree = true.
worktree_cleanup = false
"#;
pub fn global_config_path() -> Result<PathBuf> {
Ok(config_dir()?.join(GLOBAL_CONFIG_FILENAME))
}
pub fn load_global_config() -> Result<Config> {
let config_path = global_config_path()?;
if !config_path.exists() {
ensure_config_dir()?;
fs::write(&config_path, DEFAULT_CONFIG_WITH_COMMENTS)?;
return Ok(Config::default());
}
let content = fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content).map_err(|e| {
Autom8Error::Config(format!(
"Failed to parse config file at {:?}: {}",
config_path, e
))
})?;
Ok(config)
}
pub fn save_global_config(config: &Config) -> Result<()> {
let config_path = global_config_path()?;
ensure_config_dir()?;
let content = generate_config_with_comments(config);
fs::write(&config_path, content)?;
Ok(())
}
fn generate_config_with_comments(config: &Config) -> String {
format!(
r#"# Autom8 Configuration
# This file controls which states in the autom8 state machine are executed.
# Review state: Code review before committing
# - true: Run code review step to check implementation quality
# - false: Skip code review and proceed directly to commit
review = {}
# Commit state: Creating git commits
# - true: Automatically commit changes after implementation
# - false: Leave changes uncommitted (manual commit required)
commit = {}
# Pull request state: Creating pull requests
# - true: Automatically create a PR after committing
# - false: Skip PR creation (commits remain on local branch)
# Note: Requires commit = true to work
pull_request = {}
# Pull request draft mode: Create PRs as drafts
# - true: Create PRs as drafts (not ready for review)
# - false: Create PRs as regular (ready for review) PRs (default)
# Note: Only applies when pull_request = true. Has no effect otherwise.
pull_request_draft = {}
# Worktree mode: Automatic worktree creation for parallel runs
# - true: Create a dedicated worktree for each run (enables parallel sessions, default)
# - false: Run on the current branch (single session per project)
# Note: Requires a git repository. Has no effect outside of git repos.
worktree = {}
# Worktree path pattern: Pattern for naming worktree directories
# Placeholders: {{repo}} = repository name, {{branch}} = branch name (slugified)
# Default: {{repo}}-wt-{{branch}} (e.g., "myproject-wt-feature-login")
worktree_path_pattern = "{}"
# Worktree cleanup: Automatically remove worktrees after successful completion
# - true: Remove worktree directory after run completes successfully
# - false: Preserve worktrees for manual inspection/cleanup (default)
# Note: Failed runs always keep their worktrees. Only applies when worktree = true.
worktree_cleanup = {}
"#,
config.review,
config.commit,
config.pull_request,
config.pull_request_draft,
config.worktree,
config.worktree_path_pattern,
config.worktree_cleanup
)
}
const PROJECT_CONFIG_FILENAME: &str = "config.toml";
pub fn project_config_path() -> Result<PathBuf> {
Ok(project_config_dir()?.join(PROJECT_CONFIG_FILENAME))
}
pub fn project_config_path_for(project_name: &str) -> Result<PathBuf> {
Ok(project_config_dir_for(project_name)?.join(PROJECT_CONFIG_FILENAME))
}
pub fn load_project_config() -> Result<Config> {
let config_path = project_config_path()?;
if !config_path.exists() {
ensure_project_config_dir()?;
let global_config = load_global_config()?;
let content = generate_config_with_comments(&global_config);
fs::write(&config_path, content)?;
return Ok(global_config);
}
let content = fs::read_to_string(&config_path)?;
let config: Config = toml::from_str(&content).map_err(|e| {
Autom8Error::Config(format!(
"Failed to parse project config file at {:?}: {}",
config_path, e
))
})?;
Ok(config)
}
pub fn save_project_config(config: &Config) -> Result<()> {
let config_path = project_config_path()?;
ensure_project_config_dir()?;
let content = generate_config_with_comments(config);
fs::write(&config_path, content)?;
Ok(())
}
pub fn save_project_config_for(project_name: &str, config: &Config) -> Result<()> {
let config_path = project_config_path_for(project_name)?;
let config_dir = project_config_dir_for(project_name)?;
fs::create_dir_all(&config_dir)?;
let content = generate_config_with_comments(config);
fs::write(&config_path, content)?;
Ok(())
}
pub fn get_effective_config() -> Result<Config> {
let project_config_path = project_config_path()?;
let config = if project_config_path.exists() {
let content = fs::read_to_string(&project_config_path)?;
toml::from_str(&content).map_err(|e| {
Autom8Error::Config(format!(
"Failed to parse project config file at {:?}: {}",
project_config_path, e
))
})?
} else {
load_global_config()?
};
validate_config(&config).map_err(|e| Autom8Error::Config(e.to_string()))?;
Ok(config)
}
const SPEC_SUBDIR: &str = "spec";
const RUNS_SUBDIR: &str = "runs";
const SESSIONS_SUBDIR: &str = "sessions";
const PROJECT_METADATA_FILENAME: &str = "project.json";
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectMetadata {
pub repo_path: PathBuf,
}
pub fn config_dir() -> Result<PathBuf> {
let home = dirs::home_dir()
.ok_or_else(|| Autom8Error::Config("Could not determine home directory".to_string()))?;
Ok(home.join(".config").join(CONFIG_DIR_NAME))
}
pub fn ensure_config_dir() -> Result<(PathBuf, bool)> {
let dir = config_dir()?;
let created = !dir.exists();
fs::create_dir_all(&dir)?;
Ok((dir, created))
}
pub fn current_project_name() -> Result<String> {
if let Ok(Some(repo_name)) = crate::worktree::get_git_repo_name() {
return Ok(repo_name);
}
let cwd = env::current_dir().map_err(|e| {
Autom8Error::Config(format!("Could not determine current directory: {}", e))
})?;
cwd.file_name()
.and_then(|n| n.to_str())
.map(|s| s.to_string())
.ok_or_else(|| {
Autom8Error::Config("Could not determine project name from path".to_string())
})
}
pub fn project_config_dir() -> Result<PathBuf> {
let base = config_dir()?;
let project_name = current_project_name()?;
Ok(base.join(project_name))
}
pub fn project_config_dir_for(project_name: &str) -> Result<PathBuf> {
let base = config_dir()?;
Ok(base.join(project_name))
}
pub fn ensure_project_config_dir() -> Result<(PathBuf, bool)> {
let dir = project_config_dir()?;
let created = !dir.exists();
fs::create_dir_all(dir.join(SPEC_SUBDIR))?;
fs::create_dir_all(dir.join(RUNS_SUBDIR))?;
let metadata_path = dir.join(PROJECT_METADATA_FILENAME);
if !metadata_path.exists() {
if let Ok(repo_path) = crate::worktree::get_main_repo_root() {
let metadata = ProjectMetadata { repo_path };
if let Ok(content) = serde_json::to_string_pretty(&metadata) {
let _ = fs::write(&metadata_path, content);
}
}
}
Ok((dir, created))
}
pub fn get_project_repo_path(project_name: &str) -> Option<PathBuf> {
let project_dir = project_config_dir_for(project_name).ok()?;
let metadata_path = project_dir.join(PROJECT_METADATA_FILENAME);
let content = fs::read_to_string(&metadata_path).ok()?;
let metadata: ProjectMetadata = serde_json::from_str(&content).ok()?;
if metadata.repo_path.exists() {
Some(metadata.repo_path)
} else {
None
}
}
pub fn spec_dir() -> Result<PathBuf> {
Ok(project_config_dir()?.join(SPEC_SUBDIR))
}
pub fn runs_dir() -> Result<PathBuf> {
Ok(project_config_dir()?.join(RUNS_SUBDIR))
}
pub fn list_projects() -> Result<Vec<String>> {
let base = config_dir()?;
if !base.exists() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
let entries = fs::read_dir(&base)
.map_err(|e| Autom8Error::Config(format!("Could not read config directory: {}", e)))?;
for entry in entries {
let entry = entry
.map_err(|e| Autom8Error::Config(format!("Could not read directory entry: {}", e)))?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
projects.push(name.to_string());
}
}
}
projects.sort();
Ok(projects)
}
pub fn is_in_config_dir(file_path: &std::path::Path) -> Result<bool> {
let base_config = config_dir()?;
let canonical_file = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
let canonical_config = base_config.canonicalize().unwrap_or(base_config);
Ok(canonical_file.starts_with(&canonical_config))
}
#[derive(Debug)]
pub struct MoveResult {
pub dest_path: PathBuf,
pub was_moved: bool,
}
pub fn move_to_config_dir(file_path: &std::path::Path) -> Result<MoveResult> {
if is_in_config_dir(file_path)? {
let canonical = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
return Ok(MoveResult {
dest_path: canonical,
was_moved: false,
});
}
let dest_dir = spec_dir()?;
fs::create_dir_all(&dest_dir)?;
let filename = file_path
.file_name()
.ok_or_else(|| Autom8Error::Config("Could not determine filename".to_string()))?;
let dest_path = dest_dir.join(filename);
if fs::rename(file_path, &dest_path).is_err() {
fs::copy(file_path, &dest_path)?;
fs::remove_file(file_path)?;
}
Ok(MoveResult {
dest_path,
was_moved: true,
})
}
#[derive(Debug, Clone)]
pub struct ProjectStatus {
pub name: String,
pub has_active_run: bool,
pub run_status: Option<crate::state::RunStatus>,
pub incomplete_spec_count: usize,
pub total_spec_count: usize,
}
impl ProjectStatus {
pub fn needs_attention(&self) -> bool {
self.has_active_run
|| self.run_status == Some(crate::state::RunStatus::Failed)
|| self.incomplete_spec_count > 0
}
pub fn is_idle(&self) -> bool {
!self.needs_attention()
}
}
#[derive(Debug, Clone)]
pub struct ProjectTreeInfo {
pub name: String,
pub has_active_run: bool,
pub run_status: Option<crate::state::RunStatus>,
pub spec_count: usize,
pub incomplete_spec_count: usize,
pub spec_md_count: usize,
pub runs_count: usize,
pub last_run_date: Option<chrono::DateTime<chrono::Utc>>,
}
impl ProjectTreeInfo {
pub fn status_label(&self) -> &'static str {
if self.has_active_run {
"running"
} else if self.run_status == Some(crate::state::RunStatus::Failed) {
"failed"
} else if self.incomplete_spec_count > 0 {
"incomplete"
} else if self.spec_count > 0 {
"complete"
} else {
"empty"
}
}
pub fn has_content(&self) -> bool {
self.spec_count > 0 || self.spec_md_count > 0 || self.runs_count > 0 || self.has_active_run
}
}
pub fn list_projects_tree() -> Result<Vec<ProjectTreeInfo>> {
use crate::spec::Spec;
use crate::state::{RunState, RunStatus, SessionMetadata};
let projects = list_projects()?;
let mut tree_info = Vec::new();
for project_name in projects {
let project_dir = project_config_dir_for(&project_name)?;
let sessions_dir = project_dir.join(SESSIONS_SUBDIR);
let mut has_active_run = false;
let mut run_status: Option<RunStatus> = None;
let mut active_run_started_at: Option<chrono::DateTime<chrono::Utc>> = None;
if sessions_dir.exists() {
if let Ok(entries) = fs::read_dir(&sessions_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let session_path = entry.path();
if !session_path.is_dir() {
continue;
}
let metadata_path = session_path.join("metadata.json");
if let Ok(content) = fs::read_to_string(&metadata_path) {
if let Ok(metadata) = serde_json::from_str::<SessionMetadata>(&content) {
if metadata.is_running {
let state_path = session_path.join("state.json");
if let Ok(state_content) = fs::read_to_string(&state_path) {
if let Ok(state) =
serde_json::from_str::<RunState>(&state_content)
{
if state.status == RunStatus::Running {
has_active_run = true;
run_status = Some(state.status);
active_run_started_at = Some(state.started_at);
break;
}
}
}
}
}
}
}
}
}
let spec_dir = project_dir.join(SPEC_SUBDIR);
let mut specs: Vec<PathBuf> = Vec::new();
let mut incomplete_count = 0;
if spec_dir.exists() {
if let Ok(entries) = fs::read_dir(&spec_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
specs.push(path.clone());
if let Ok(spec) = Spec::load(&path) {
if spec.is_incomplete() {
incomplete_count += 1;
}
}
}
}
}
}
let spec_md_count = if spec_dir.exists() {
fs::read_dir(&spec_dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file()
&& e.path().extension().is_some_and(|ext| ext == "md")
})
.count()
})
.unwrap_or(0)
} else {
0
};
let runs_dir = project_dir.join(RUNS_SUBDIR);
let mut archived_runs: Vec<RunState> = Vec::new();
if runs_dir.exists() {
if let Ok(entries) = fs::read_dir(&runs_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().is_some_and(|e| e == "json") {
if let Ok(content) = fs::read_to_string(&path) {
if let Ok(state) = serde_json::from_str::<RunState>(&content) {
archived_runs.push(state);
}
}
}
}
}
}
archived_runs.sort_by(|a, b| b.started_at.cmp(&a.started_at));
let runs_count = archived_runs.len();
let last_run_date = if has_active_run {
active_run_started_at
} else {
archived_runs
.first()
.and_then(|r| r.finished_at.or(Some(r.started_at)))
};
tree_info.push(ProjectTreeInfo {
name: project_name,
has_active_run,
run_status,
spec_count: specs.len(),
incomplete_spec_count: incomplete_count,
spec_md_count,
runs_count,
last_run_date,
});
}
Ok(tree_info)
}
#[derive(Debug, Clone)]
pub struct ProjectDescription {
pub name: String,
pub path: PathBuf,
pub has_active_run: bool,
pub run_status: Option<crate::state::RunStatus>,
pub current_story: Option<String>,
pub current_branch: Option<String>,
pub specs: Vec<SpecSummary>,
pub spec_md_count: usize,
pub runs_count: usize,
}
#[derive(Debug, Clone)]
pub struct SpecSummary {
pub filename: String,
pub path: PathBuf,
pub project_name: String,
pub branch_name: String,
pub description: String,
pub stories: Vec<StorySummary>,
pub completed_count: usize,
pub total_count: usize,
pub is_active: bool,
}
#[derive(Debug, Clone)]
pub struct StorySummary {
pub id: String,
pub title: String,
pub passes: bool,
}
pub fn project_exists(project_name: &str) -> Result<bool> {
let project_dir = project_config_dir_for(project_name)?;
Ok(project_dir.exists())
}
pub fn get_project_description(project_name: &str) -> Result<Option<ProjectDescription>> {
use crate::spec::Spec;
use crate::state::StateManager;
let project_dir = project_config_dir_for(project_name)?;
if !project_dir.exists() {
return Ok(None);
}
let sm = StateManager::for_project(project_name)?;
let run_state = sm.load_current().ok().flatten();
let has_active_run = run_state
.as_ref()
.map(|s| s.status == crate::state::RunStatus::Running)
.unwrap_or(false);
let run_status = run_state.as_ref().map(|s| s.status);
let current_story = run_state.as_ref().and_then(|s| s.current_story.clone());
let current_branch = run_state.map(|s| s.branch);
let spec_paths = sm.list_specs().unwrap_or_default();
let mut specs = Vec::new();
for spec_path in spec_paths {
if let Ok(spec) = Spec::load(&spec_path) {
let stories: Vec<StorySummary> = spec
.user_stories
.iter()
.map(|s| StorySummary {
id: s.id.clone(),
title: s.title.clone(),
passes: s.passes,
})
.collect();
let completed_count = stories.iter().filter(|s| s.passes).count();
let total_count = stories.len();
let filename = spec_path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("unknown")
.to_string();
let is_active = has_active_run
&& current_branch
.as_ref()
.is_some_and(|b| b == &spec.branch_name);
specs.push(SpecSummary {
filename,
path: spec_path,
project_name: spec.project,
branch_name: spec.branch_name.clone(),
description: spec.description,
stories,
completed_count,
total_count,
is_active,
});
}
}
let spec_dir = project_dir.join(SPEC_SUBDIR);
let spec_md_count = if spec_dir.exists() {
fs::read_dir(&spec_dir)
.map(|entries| {
entries
.filter_map(|e| e.ok())
.filter(|e| {
e.path().is_file() && e.path().extension().is_some_and(|ext| ext == "md")
})
.count()
})
.unwrap_or(0)
} else {
0
};
let runs_count = sm.list_archived().unwrap_or_default().len();
Ok(Some(ProjectDescription {
name: project_name.to_string(),
path: project_dir,
has_active_run,
run_status,
current_story,
current_branch,
specs,
spec_md_count,
runs_count,
}))
}
pub fn global_status() -> Result<Vec<ProjectStatus>> {
use crate::spec::Spec;
use crate::state::StateManager;
let projects = list_projects()?;
let mut statuses = Vec::new();
for project_name in projects {
let sm = StateManager::for_project(&project_name)?;
let run_state = sm.load_current().ok().flatten();
let has_active_run = run_state
.as_ref()
.map(|s| s.status == crate::state::RunStatus::Running)
.unwrap_or(false);
let run_status = run_state.map(|s| s.status);
let specs = sm.list_specs().unwrap_or_default();
let mut incomplete_count = 0;
let mut total_count = 0;
for spec_path in &specs {
if let Ok(spec) = Spec::load(spec_path) {
total_count += 1;
if spec.is_incomplete() {
incomplete_count += 1;
}
}
}
statuses.push(ProjectStatus {
name: project_name,
has_active_run,
run_status,
incomplete_spec_count: incomplete_count,
total_spec_count: total_count,
});
}
Ok(statuses)
}
#[cfg(test)]
fn global_status_at(base_config_dir: &std::path::Path) -> Result<Vec<ProjectStatus>> {
use crate::spec::Spec;
use crate::state::StateManager;
let projects = list_projects_at(base_config_dir)?;
let mut statuses = Vec::new();
for project_name in projects {
let project_dir = base_config_dir.join(&project_name);
let sm = StateManager::with_dir(project_dir);
let run_state = sm.load_current().ok().flatten();
let has_active_run = run_state
.as_ref()
.map(|s| s.status == crate::state::RunStatus::Running)
.unwrap_or(false);
let run_status = run_state.map(|s| s.status);
let specs = sm.list_specs().unwrap_or_default();
let mut incomplete_count = 0;
let mut total_count = 0;
for spec_path in &specs {
if let Ok(spec) = Spec::load(spec_path) {
total_count += 1;
if spec.is_incomplete() {
incomplete_count += 1;
}
}
}
statuses.push(ProjectStatus {
name: project_name,
has_active_run,
run_status,
incomplete_spec_count: incomplete_count,
total_spec_count: total_count,
});
}
Ok(statuses)
}
#[cfg(test)]
fn list_projects_at(base_config_dir: &std::path::Path) -> Result<Vec<String>> {
if !base_config_dir.exists() {
return Ok(Vec::new());
}
let mut projects = Vec::new();
let entries = fs::read_dir(base_config_dir)
.map_err(|e| Autom8Error::Config(format!("Could not read config directory: {}", e)))?;
for entry in entries {
let entry = entry
.map_err(|e| Autom8Error::Config(format!("Could not read directory entry: {}", e)))?;
let path = entry.path();
if path.is_dir() {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
projects.push(name.to_string());
}
}
}
projects.sort();
Ok(projects)
}
#[cfg(test)]
fn ensure_config_dir_at(base: &std::path::Path) -> Result<(PathBuf, bool)> {
let dir = base.join(".config").join(CONFIG_DIR_NAME);
let created = !dir.exists();
fs::create_dir_all(&dir)?;
Ok((dir, created))
}
#[cfg(test)]
fn ensure_project_config_dir_at(
base: &std::path::Path,
project_name: &str,
) -> Result<(PathBuf, bool)> {
let dir = base
.join(".config")
.join(CONFIG_DIR_NAME)
.join(project_name);
let created = !dir.exists();
fs::create_dir_all(dir.join(SPEC_SUBDIR))?;
fs::create_dir_all(dir.join(RUNS_SUBDIR))?;
Ok((dir, created))
}
#[cfg(test)]
fn spec_dir_at(project_config_dir: &std::path::Path) -> PathBuf {
project_config_dir.join(SPEC_SUBDIR)
}
#[cfg(test)]
fn is_in_config_dir_at(
base_config_dir: &std::path::Path,
file_path: &std::path::Path,
) -> Result<bool> {
let canonical_file = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
let canonical_config = base_config_dir
.canonicalize()
.unwrap_or_else(|_| base_config_dir.to_path_buf());
Ok(canonical_file.starts_with(&canonical_config))
}
#[cfg(test)]
fn move_to_config_dir_at(
dest_spec_dir: &std::path::Path,
file_path: &std::path::Path,
) -> Result<MoveResult> {
if is_in_config_dir_at(dest_spec_dir, file_path)? {
let canonical = file_path
.canonicalize()
.unwrap_or_else(|_| file_path.to_path_buf());
return Ok(MoveResult {
dest_path: canonical,
was_moved: false,
});
}
fs::create_dir_all(dest_spec_dir)?;
let filename = file_path
.file_name()
.ok_or_else(|| Autom8Error::Config("Could not determine filename".to_string()))?;
let dest_path = dest_spec_dir.join(filename);
if fs::rename(file_path, &dest_path).is_err() {
fs::copy(file_path, &dest_path)?;
fs::remove_file(file_path)?;
}
Ok(MoveResult {
dest_path,
was_moved: true,
})
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_ensure_config_dir_at_creates_directory() {
let temp_dir = TempDir::new().unwrap();
let expected_path = temp_dir.path().join(".config").join("autom8");
assert!(!expected_path.exists());
let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
assert_eq!(path, expected_path);
assert!(created);
assert!(expected_path.exists());
assert!(expected_path.is_dir());
}
#[test]
fn test_ensure_config_dir_at_reports_existing_directory() {
let temp_dir = TempDir::new().unwrap();
let expected_path = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&expected_path).unwrap();
assert!(expected_path.exists());
let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
assert_eq!(path, expected_path);
assert!(!created); assert!(expected_path.exists());
}
#[test]
fn test_ensure_config_dir_at_creates_parent_directories() {
let temp_dir = TempDir::new().unwrap();
let config_path = temp_dir.path().join(".config");
assert!(!config_path.exists());
let (path, created) = ensure_config_dir_at(temp_dir.path()).unwrap();
assert!(created);
assert!(path.exists());
assert!(config_path.exists()); }
#[test]
fn test_spec_dir_at_returns_spec_subdirectory() {
let project_config_dir = PathBuf::from("/some/project/config/dir");
let result = spec_dir_at(&project_config_dir);
assert_eq!(result, PathBuf::from("/some/project/config/dir/spec"));
}
#[test]
fn test_spec_dir_at_with_temp_dir() {
let temp_dir = TempDir::new().unwrap();
let (project_dir, _) =
ensure_project_config_dir_at(temp_dir.path(), "test-project").unwrap();
let spec_dir = spec_dir_at(&project_dir);
assert_eq!(spec_dir, project_dir.join("spec"));
assert!(spec_dir.exists());
assert!(spec_dir.is_dir());
}
#[test]
fn test_is_in_config_dir_at_returns_true_for_file_inside_config() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
fs::create_dir_all(&config_dir).unwrap();
let file_inside = config_dir.join("subdir").join("file.txt");
fs::create_dir_all(file_inside.parent().unwrap()).unwrap();
fs::write(&file_inside, "test").unwrap();
let result = is_in_config_dir_at(&config_dir, &file_inside).unwrap();
assert!(result);
}
#[test]
fn test_is_in_config_dir_at_returns_false_for_file_outside_config() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
let other_dir = temp_dir.path().join("other");
fs::create_dir_all(&config_dir).unwrap();
fs::create_dir_all(&other_dir).unwrap();
let file_outside = other_dir.join("file.txt");
fs::write(&file_outside, "test").unwrap();
let result = is_in_config_dir_at(&config_dir, &file_outside).unwrap();
assert!(!result);
}
#[test]
fn test_is_in_config_dir_at_handles_nonexistent_path() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
fs::create_dir_all(&config_dir).unwrap();
let canonical_config = config_dir.canonicalize().unwrap();
let nonexistent_file = canonical_config.join("does_not_exist.txt");
let result = is_in_config_dir_at(&config_dir, &nonexistent_file).unwrap();
assert!(result);
}
#[test]
fn test_move_to_config_dir_at_moves_file_to_dest_dir() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let dest_spec_dir = temp_dir.path().join("dest_config").join("spec");
fs::create_dir_all(&source_dir).unwrap();
let source_file = source_dir.join("test-file.json");
let content = r#"{"test": "data"}"#;
fs::write(&source_file, content).unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
assert!(result.was_moved, "File should have been moved");
assert!(result.dest_path.exists(), "Destination file should exist");
assert!(
!source_file.exists(),
"Source file should be deleted after move"
);
assert!(
result.dest_path.starts_with(&dest_spec_dir),
"File should be in the specified dest_spec_dir"
);
assert_eq!(
fs::read_to_string(&result.dest_path).unwrap(),
content,
"Content should match"
);
}
#[test]
fn test_move_to_config_dir_at_returns_unchanged_if_already_in_dest() {
let temp_dir = TempDir::new().unwrap();
let dest_spec_dir = temp_dir.path().join("config").join("spec");
fs::create_dir_all(&dest_spec_dir).unwrap();
let existing_file = dest_spec_dir.join("already-here.md");
fs::write(&existing_file, "# Already here").unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &existing_file).unwrap();
assert!(!result.was_moved, "File should not have been moved");
assert!(
existing_file.exists(),
"File should still exist in original location"
);
assert_eq!(
result.dest_path.canonicalize().unwrap(),
existing_file.canonicalize().unwrap(),
"Path should be the canonical original"
);
}
#[test]
fn test_move_to_config_dir_at_preserves_filename() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let dest_spec_dir = temp_dir.path().join("config").join("spec");
fs::create_dir_all(&source_dir).unwrap();
let source_file = source_dir.join("my-custom-filename.txt");
fs::write(&source_file, "test content").unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
assert_eq!(
result.dest_path.file_name().unwrap().to_str().unwrap(),
"my-custom-filename.txt",
"Filename should be preserved"
);
}
#[test]
fn test_move_to_config_dir_at_creates_dest_dir_if_missing() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let dest_spec_dir = temp_dir
.path()
.join("nonexistent")
.join("nested")
.join("spec");
fs::create_dir_all(&source_dir).unwrap();
let source_file = source_dir.join("test.md");
fs::write(&source_file, "# Test").unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
assert!(result.was_moved, "File should have been moved");
assert!(
dest_spec_dir.exists(),
"Destination directory should be created"
);
assert!(result.dest_path.exists(), "Destination file should exist");
}
#[test]
fn test_ensure_project_config_dir_at_creates_all_subdirs() {
let temp_dir = TempDir::new().unwrap();
let project_name = "test-project";
let (path, created) = ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
assert!(created);
assert!(path.exists());
assert!(path.ends_with(project_name));
assert!(path.join("spec").exists());
assert!(path.join("spec").is_dir());
assert!(path.join("runs").exists());
assert!(path.join("runs").is_dir());
}
#[test]
fn test_ensure_project_config_dir_at_reports_existing() {
let temp_dir = TempDir::new().unwrap();
let project_name = "existing-project";
let (path1, created1) =
ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
assert!(created1);
let (path2, created2) =
ensure_project_config_dir_at(temp_dir.path(), project_name).unwrap();
assert!(!created2);
assert_eq!(path1, path2);
}
#[test]
fn test_ensure_project_config_dir_at_different_projects_share_nothing() {
let temp_dir = TempDir::new().unwrap();
let (path1, _) = ensure_project_config_dir_at(temp_dir.path(), "project-a").unwrap();
let (path2, _) = ensure_project_config_dir_at(temp_dir.path(), "project-b").unwrap();
assert_ne!(path1, path2);
assert!(path1.exists());
assert!(path2.exists());
assert!(path1.join("spec").exists());
assert!(path2.join("spec").exists());
}
#[test]
fn test_ensure_project_config_dir_creates_directory_structure() {
let temp_dir = TempDir::new().unwrap();
let result = ensure_project_config_dir_at(temp_dir.path(), "test-project");
assert!(result.is_ok());
let (path, created) = result.unwrap();
assert!(created);
assert!(path.exists());
assert!(path.join("spec").exists());
assert!(path.join("runs").exists());
}
#[test]
fn test_is_in_config_dir_true_for_file_in_config() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
fs::create_dir_all(&config_dir).unwrap();
let test_file = config_dir.join("test.json");
fs::write(&test_file, "{}").unwrap();
let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
assert!(result, "File in config dir should return true");
}
#[test]
fn test_is_in_config_dir_false_for_file_outside_config() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.json");
fs::write(&test_file, "{}").unwrap();
let result = is_in_config_dir(&test_file).unwrap();
assert!(!result, "File outside config dir should return false");
}
#[test]
fn test_is_in_config_dir_true_for_file_in_subdirectory() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
let spec_dir = config_dir.join("spec");
fs::create_dir_all(&spec_dir).unwrap();
let test_file = spec_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
assert!(result, "File in config subdirectory should return true");
}
#[test]
fn test_is_in_config_dir_true_for_file_in_different_project() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join("config");
let other_project_spec_dir = config_dir
.join("some-other-project-wt-feature")
.join("spec");
fs::create_dir_all(&other_project_spec_dir).unwrap();
let test_file = other_project_spec_dir.join("test.md");
fs::write(&test_file, "# Test").unwrap();
let result = is_in_config_dir_at(&config_dir, &test_file).unwrap();
assert!(
result,
"File in different project's config dir should return true"
);
}
#[test]
fn test_move_to_config_dir_moves_md_to_spec() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let dest_spec_dir = temp_dir.path().join("config").join("spec");
fs::create_dir_all(&source_dir).unwrap();
let source_file = source_dir.join("test-spec.md");
let content = "# Test Spec\n\nThis is a test.";
fs::write(&source_file, content).unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
assert!(result.was_moved, "File should have been moved");
assert!(result.dest_path.exists(), "Destination file should exist");
assert!(
!source_file.exists(),
"Source file should be deleted after move"
);
assert!(
result.dest_path.parent().unwrap().ends_with("spec"),
"MD files should go to spec/ directory"
);
assert_eq!(
fs::read_to_string(&result.dest_path).unwrap(),
content,
"Content should match"
);
}
#[test]
fn test_move_to_config_dir_no_move_if_already_in_config() {
let temp_dir = TempDir::new().unwrap();
let dest_spec_dir = temp_dir.path().join("config").join("spec");
fs::create_dir_all(&dest_spec_dir).unwrap();
let existing_file = dest_spec_dir.join("existing-test.md");
fs::write(&existing_file, "# Already here").unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &existing_file).unwrap();
assert!(!result.was_moved, "File should not have been moved");
assert!(
existing_file.exists(),
"File should still exist in original location"
);
assert_eq!(
result.dest_path.canonicalize().unwrap(),
existing_file.canonicalize().unwrap(),
"Path should be the original"
);
}
#[test]
fn test_move_to_config_dir_unknown_extension_goes_to_spec() {
let temp_dir = TempDir::new().unwrap();
let source_dir = temp_dir.path().join("source");
let dest_spec_dir = temp_dir.path().join("config").join("spec");
fs::create_dir_all(&source_dir).unwrap();
let source_file = source_dir.join("test-file.txt");
fs::write(&source_file, "Some content").unwrap();
let result = move_to_config_dir_at(&dest_spec_dir, &source_file).unwrap();
assert!(result.was_moved, "File should have been moved");
assert!(
!source_file.exists(),
"Source file should be deleted after move"
);
assert!(
result.dest_path.parent().unwrap().ends_with("spec"),
"Unknown extensions should default to spec/ directory"
);
}
#[test]
fn test_move_result_struct() {
let result = MoveResult {
dest_path: PathBuf::from("/test/path"),
was_moved: true,
};
assert_eq!(result.dest_path, PathBuf::from("/test/path"));
assert!(result.was_moved);
}
#[test]
fn test_list_projects_empty_when_no_projects() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let projects = list_projects_at(&config_dir).unwrap();
assert!(
projects.is_empty(),
"Should return empty list when no projects exist"
);
}
#[test]
fn test_list_projects_returns_sorted_list() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(config_dir.join("zebra")).unwrap();
fs::create_dir_all(config_dir.join("alpha")).unwrap();
fs::create_dir_all(config_dir.join("mango")).unwrap();
let projects = list_projects_at(&config_dir).unwrap();
assert_eq!(projects.len(), 3);
assert_eq!(projects[0], "alpha", "First project should be 'alpha'");
assert_eq!(projects[1], "mango", "Second project should be 'mango'");
assert_eq!(projects[2], "zebra", "Third project should be 'zebra'");
}
#[test]
fn test_list_projects_ignores_files() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
fs::create_dir_all(config_dir.join("my-project")).unwrap();
fs::write(config_dir.join("some-file.txt"), "not a project").unwrap();
let projects = list_projects_at(&config_dir).unwrap();
assert_eq!(projects.len(), 1, "Should only include directories");
assert_eq!(projects[0], "my-project");
}
#[test]
fn test_list_projects_empty_when_dir_does_not_exist() {
let temp_dir = TempDir::new().unwrap();
let non_existent_dir = temp_dir.path().join("does-not-exist");
let projects = list_projects_at(&non_existent_dir).unwrap();
assert!(
projects.is_empty(),
"Should return empty list for non-existent directory"
);
}
#[test]
fn test_project_status_needs_attention_with_active_run() {
let status = ProjectStatus {
name: "test-project".to_string(),
has_active_run: true,
run_status: Some(crate::state::RunStatus::Running),
incomplete_spec_count: 0,
total_spec_count: 0,
};
assert!(status.needs_attention(), "Active run should need attention");
assert!(!status.is_idle());
}
#[test]
fn test_project_status_needs_attention_with_failed_run() {
let status = ProjectStatus {
name: "test-project".to_string(),
has_active_run: false,
run_status: Some(crate::state::RunStatus::Failed),
incomplete_spec_count: 0,
total_spec_count: 0,
};
assert!(status.needs_attention(), "Failed run should need attention");
assert!(!status.is_idle());
}
#[test]
fn test_project_status_needs_attention_with_incomplete_specs() {
let status = ProjectStatus {
name: "test-project".to_string(),
has_active_run: false,
run_status: None,
incomplete_spec_count: 2,
total_spec_count: 3,
};
assert!(
status.needs_attention(),
"Incomplete specs should need attention"
);
assert!(!status.is_idle());
}
#[test]
fn test_project_status_idle_when_no_work() {
let status = ProjectStatus {
name: "test-project".to_string(),
has_active_run: false,
run_status: Some(crate::state::RunStatus::Completed),
incomplete_spec_count: 0,
total_spec_count: 1,
};
assert!(
!status.needs_attention(),
"Completed project should not need attention"
);
assert!(status.is_idle());
}
#[test]
fn test_project_status_idle_when_no_runs_no_specs() {
let status = ProjectStatus {
name: "test-project".to_string(),
has_active_run: false,
run_status: None,
incomplete_spec_count: 0,
total_spec_count: 0,
};
assert!(!status.needs_attention());
assert!(status.is_idle());
}
#[test]
fn test_global_status_empty_when_no_projects() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let statuses = global_status_at(&config_dir).unwrap();
assert!(
statuses.is_empty(),
"Should return empty list when no projects exist"
);
}
#[test]
fn test_global_status_returns_all_projects() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(config_dir.join("project-a").join("spec")).unwrap();
fs::create_dir_all(config_dir.join("project-b").join("spec")).unwrap();
let statuses = global_status_at(&config_dir).unwrap();
assert_eq!(statuses.len(), 2);
assert_eq!(statuses[0].name, "project-a");
assert_eq!(statuses[1].name, "project-b");
}
#[test]
fn test_global_status_detects_active_run() {
use crate::state::{RunState, StateManager};
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("active-project");
fs::create_dir_all(project_dir.join("spec")).unwrap();
let sm = StateManager::with_dir(project_dir);
let run_state = RunState::new(PathBuf::from("test.json"), "test-branch".to_string());
sm.save(&run_state).unwrap();
let statuses = global_status_at(&config_dir).unwrap();
assert_eq!(statuses.len(), 1);
assert!(statuses[0].has_active_run);
assert_eq!(
statuses[0].run_status,
Some(crate::state::RunStatus::Running)
);
}
#[test]
fn test_global_status_counts_incomplete_specs() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("spec-project");
let spec_dir = project_dir.join("spec");
fs::create_dir_all(&spec_dir).unwrap();
let incomplete_prd = r#"{
"project": "Test Project",
"branchName": "test",
"description": "Test",
"userStories": [
{"id": "US-001", "title": "Story 1", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": false}
]
}"#;
fs::write(spec_dir.join("spec-test.json"), incomplete_prd).unwrap();
let complete_prd = r#"{
"project": "Complete Project",
"branchName": "test",
"description": "Test",
"userStories": [
{"id": "US-001", "title": "Story 1", "description": "Desc", "acceptanceCriteria": [], "priority": 1, "passes": true}
]
}"#;
fs::write(spec_dir.join("spec-complete.json"), complete_prd).unwrap();
let statuses = global_status_at(&config_dir).unwrap();
assert_eq!(statuses.len(), 1);
assert_eq!(statuses[0].incomplete_spec_count, 1);
assert_eq!(statuses[0].total_spec_count, 2);
}
#[test]
fn test_project_tree_info_status_label_running() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: true,
run_status: Some(crate::state::RunStatus::Running),
spec_count: 1,
incomplete_spec_count: 0,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert_eq!(info.status_label(), "running");
}
#[test]
fn test_project_tree_info_status_label_failed() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: false,
run_status: Some(crate::state::RunStatus::Failed),
spec_count: 1,
incomplete_spec_count: 0,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert_eq!(info.status_label(), "failed");
}
#[test]
fn test_project_tree_info_status_label_incomplete() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: false,
run_status: None,
spec_count: 2,
incomplete_spec_count: 1,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert_eq!(info.status_label(), "incomplete");
}
#[test]
fn test_project_tree_info_status_label_complete() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: false,
run_status: None,
spec_count: 2,
incomplete_spec_count: 0,
spec_md_count: 1,
runs_count: 0,
last_run_date: None,
};
assert_eq!(info.status_label(), "complete");
}
#[test]
fn test_project_tree_info_status_label_empty() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: false,
run_status: None,
spec_count: 0,
incomplete_spec_count: 0,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert_eq!(info.status_label(), "empty");
}
#[test]
fn test_project_tree_info_has_content_true() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: false,
run_status: None,
spec_count: 1,
incomplete_spec_count: 0,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert!(info.has_content());
}
#[test]
fn test_project_tree_info_has_content_false() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: false,
run_status: None,
spec_count: 0,
incomplete_spec_count: 0,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert!(!info.has_content());
}
#[test]
fn test_project_tree_info_has_content_with_active_run() {
let info = ProjectTreeInfo {
name: "test".to_string(),
has_active_run: true,
run_status: Some(crate::state::RunStatus::Running),
spec_count: 0,
incomplete_spec_count: 0,
spec_md_count: 0,
runs_count: 0,
last_run_date: None,
};
assert!(info.has_content());
}
#[test]
fn test_us008_project_exists_false_for_nonexistent() {
let result = project_exists("nonexistent-project-xyz-12345");
assert!(result.is_ok());
assert!(!result.unwrap(), "nonexistent project should return false");
}
#[test]
fn test_us008_get_project_description_nonexistent_project() {
let result = get_project_description("nonexistent-project-xyz-12345");
assert!(result.is_ok());
assert!(
result.unwrap().is_none(),
"nonexistent project should return None"
);
}
#[test]
fn test_us008_spec_summary_struct_fields() {
let summary = SpecSummary {
filename: "test.json".to_string(),
path: PathBuf::from("/test"),
project_name: "Test Project".to_string(),
branch_name: "feature/test".to_string(),
description: "Test description".to_string(),
stories: vec![StorySummary {
id: "US-001".to_string(),
title: "Test Story".to_string(),
passes: true,
}],
completed_count: 1,
total_count: 1,
is_active: false,
};
assert_eq!(summary.filename, "test.json");
assert_eq!(summary.project_name, "Test Project");
assert_eq!(summary.branch_name, "feature/test");
assert_eq!(summary.completed_count, 1);
assert_eq!(summary.total_count, 1);
assert!(!summary.is_active);
}
#[test]
fn test_us008_story_summary_struct_fields() {
let story = StorySummary {
id: "US-001".to_string(),
title: "Test Story".to_string(),
passes: false,
};
assert_eq!(story.id, "US-001");
assert_eq!(story.title, "Test Story");
assert!(!story.passes);
}
#[test]
fn test_config_default_all_true() {
let config = Config::default();
assert!(config.review, "review should default to true");
assert!(config.commit, "commit should default to true");
assert!(config.pull_request, "pull_request should default to true");
assert!(config.worktree, "worktree should default to true");
}
#[test]
fn test_config_serialize_to_toml() {
let config = Config::default();
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("review = true"));
assert!(toml_str.contains("commit = true"));
assert!(toml_str.contains("pull_request = true"));
assert!(toml_str.contains("worktree = true"));
}
#[test]
fn test_config_deserialize_from_toml() {
let toml_str = r#"
review = false
commit = true
pull_request = false
worktree = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(!config.review);
assert!(config.commit);
assert!(!config.pull_request);
assert!(config.worktree);
}
#[test]
fn test_config_deserialize_partial_toml_uses_defaults() {
let toml_str = r#"
commit = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.review, "missing review should default to true");
assert!(!config.commit, "commit should be false as specified");
assert!(
config.pull_request,
"missing pull_request should default to true"
);
assert!(config.worktree, "missing worktree should default to true");
}
#[test]
fn test_config_deserialize_empty_toml_uses_all_defaults() {
let toml_str = "";
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.review);
assert!(config.commit);
assert!(config.pull_request);
assert!(config.worktree);
}
#[test]
fn test_config_roundtrip() {
let original = Config {
review: false,
commit: true,
pull_request: false,
worktree: true,
..Default::default()
};
let toml_str = toml::to_string(&original).unwrap();
let deserialized: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(original, deserialized);
}
#[test]
fn test_config_equality() {
let config1 = Config::default();
let config2 = Config::default();
assert_eq!(config1, config2);
let config3 = Config {
review: false,
..Default::default()
};
assert_ne!(config1, config3);
}
#[test]
fn test_config_clone() {
let original = Config {
review: false,
commit: true,
pull_request: false,
worktree: true,
..Default::default()
};
let cloned = original.clone();
assert_eq!(original, cloned);
}
#[test]
fn test_config_debug_format() {
let config = Config::default();
let debug_str = format!("{:?}", config);
assert!(debug_str.contains("Config"));
assert!(debug_str.contains("review"));
assert!(debug_str.contains("commit"));
assert!(debug_str.contains("pull_request"));
assert!(debug_str.contains("worktree"));
}
#[test]
fn test_generate_config_with_comments_includes_all_fields() {
let config = Config::default();
let content = generate_config_with_comments(&config);
assert!(content.contains("review = true"));
assert!(content.contains("commit = true"));
assert!(content.contains("pull_request = true"));
assert!(content.contains("worktree = true"));
}
#[test]
fn test_generate_config_with_comments_has_explanatory_comments() {
let config = Config::default();
let content = generate_config_with_comments(&config);
assert!(content.contains("# Review state"));
assert!(content.contains("# Commit state"));
assert!(content.contains("# Pull request state"));
assert!(content.contains("# Worktree mode"));
assert!(content.contains("- true:"));
assert!(content.contains("- false:"));
}
#[test]
fn test_generate_config_with_comments_preserves_custom_values() {
let config = Config {
review: false,
commit: true,
pull_request: false,
worktree: true,
..Default::default()
};
let content = generate_config_with_comments(&config);
assert!(content.contains("review = false"));
assert!(content.contains("commit = true"));
assert!(content.contains("pull_request = false"));
assert!(content.contains("worktree = true"));
}
#[test]
fn test_default_config_with_comments_is_valid_toml() {
let config: Config = toml::from_str(DEFAULT_CONFIG_WITH_COMMENTS).unwrap();
assert!(config.review);
assert!(config.commit);
assert!(config.pull_request);
assert!(config.worktree);
}
#[test]
fn test_load_global_config_creates_file_when_missing() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
assert!(
!config_path.exists(),
"Config file should not exist initially"
);
let content = DEFAULT_CONFIG_WITH_COMMENTS;
fs::write(&config_path, content).unwrap();
let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(loaded, Config::default());
}
#[test]
fn test_save_and_load_global_config_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
let custom_config = Config {
review: false,
commit: true,
pull_request: false,
..Default::default()
};
let content = generate_config_with_comments(&custom_config);
fs::write(&config_path, content).unwrap();
let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(loaded, custom_config);
}
#[test]
fn test_load_global_config_handles_partial_config() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
let partial_content = r#"
# Partial config
review = false
commit = true
"#;
fs::write(&config_path, partial_content).unwrap();
let loaded: Config = toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap();
assert!(!loaded.review);
assert!(loaded.commit);
assert!(
loaded.pull_request,
"Missing pull_request should default to true"
);
}
#[test]
fn test_generated_config_includes_note_about_pr_requiring_commit() {
let config = Config::default();
let content = generate_config_with_comments(&config);
assert!(
content.contains("Requires commit = true"),
"Config should note that PR requires commit"
);
}
#[test]
fn test_global_config_file_has_comments_after_save() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let config_path = config_dir.join("config.toml");
let config = Config::default();
let content = generate_config_with_comments(&config);
fs::write(&config_path, content).unwrap();
let raw_content = fs::read_to_string(&config_path).unwrap();
assert!(
raw_content.contains("#"),
"Config file should contain comments"
);
assert!(
raw_content.contains("# Autom8 Configuration"),
"Config file should have header comment"
);
}
#[test]
fn test_us003_project_config_path_for_returns_correct_path() {
let path = project_config_path_for("my-test-project").unwrap();
assert!(path.ends_with("config.toml"));
assert!(path.parent().unwrap().ends_with("my-test-project"));
}
#[test]
fn test_us003_load_project_config_creates_from_global_when_missing() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config {
review: true,
commit: false,
pull_request: false,
..Default::default()
};
let global_path = config_dir.join("config.toml");
let global_content = generate_config_with_comments(&global_config);
fs::write(&global_path, &global_content).unwrap();
let project_dir = config_dir.join("test-project");
fs::create_dir_all(project_dir.join("spec")).unwrap();
fs::create_dir_all(project_dir.join("runs")).unwrap();
let project_config_path = project_dir.join("config.toml");
assert!(
!project_config_path.exists(),
"Project config should not exist initially"
);
fs::write(&project_config_path, &global_content).unwrap();
assert!(
project_config_path.exists(),
"Project config should be created when missing"
);
let loaded: Config =
toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
assert_eq!(
loaded, global_config,
"Project config should match global config"
);
}
#[test]
fn test_us003_load_project_config_preserves_comments() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let global_config = Config::default();
let global_path = config_dir.join("config.toml");
let global_content = generate_config_with_comments(&global_config);
fs::write(&global_path, &global_content).unwrap();
let project_config_path = project_dir.join("config.toml");
assert!(!project_config_path.exists());
fs::write(&project_config_path, &global_content).unwrap();
let raw_content = fs::read_to_string(&project_config_path).unwrap();
assert!(
raw_content.contains("#"),
"Project config should contain comments"
);
assert!(
raw_content.contains("# Autom8 Configuration"),
"Project config should have header comment"
);
assert!(
raw_content.contains("# Review state"),
"Project config should have review state comment"
);
}
#[test]
fn test_us003_save_project_config_creates_file() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("test-project");
fs::create_dir_all(project_dir.join("spec")).unwrap();
fs::create_dir_all(project_dir.join("runs")).unwrap();
let config = Config {
review: false,
commit: true,
pull_request: true,
..Default::default()
};
let project_config_path = project_dir.join("config.toml");
let content = generate_config_with_comments(&config);
fs::write(&project_config_path, &content).unwrap();
assert!(project_config_path.exists());
let loaded: Config =
toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
assert_eq!(loaded, config);
}
#[test]
fn test_us003_save_project_config_preserves_comments() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let config = Config::default();
let project_config_path = project_dir.join("config.toml");
let content = generate_config_with_comments(&config);
fs::write(&project_config_path, &content).unwrap();
let raw_content = fs::read_to_string(&project_config_path).unwrap();
assert!(
raw_content.contains("#"),
"Saved config should contain comments"
);
assert!(
raw_content.contains("# Autom8 Configuration"),
"Saved config should have header comment"
);
}
#[test]
fn test_us003_get_effective_config_returns_project_if_exists() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config::default();
let global_path = config_dir.join("config.toml");
fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
let project_config = Config {
review: false,
commit: false,
pull_request: false,
..Default::default()
};
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let project_path = project_dir.join("config.toml");
fs::write(
&project_path,
generate_config_with_comments(&project_config),
)
.unwrap();
let effective_path = if project_path.exists() {
&project_path
} else {
&global_path
};
let effective: Config =
toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
assert_eq!(
effective, project_config,
"Should return project config when it exists"
);
}
#[test]
fn test_us003_get_effective_config_returns_global_when_project_missing() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config {
review: true,
commit: true,
pull_request: false,
..Default::default()
};
let global_path = config_dir.join("config.toml");
let content = generate_config_with_comments(&global_config);
fs::write(&global_path, content).unwrap();
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let project_config_path = project_dir.join("config.toml");
assert!(
!project_config_path.exists(),
"Project config should not exist"
);
assert!(global_path.exists(), "Global config should exist");
let loaded: Config = toml::from_str(&fs::read_to_string(&global_path).unwrap()).unwrap();
assert_eq!(loaded, global_config);
}
#[test]
fn test_us003_project_config_takes_precedence_over_global() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config {
review: true,
commit: true,
pull_request: true,
..Default::default()
};
let global_path = config_dir.join("config.toml");
fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
let project_config = Config {
review: false,
commit: true,
pull_request: false,
..Default::default()
};
let project_dir = config_dir.join("my-project");
fs::create_dir_all(&project_dir).unwrap();
let project_path = project_dir.join("config.toml");
fs::write(
&project_path,
generate_config_with_comments(&project_config),
)
.unwrap();
let effective_path = if project_path.exists() {
&project_path
} else {
&global_path
};
let effective: Config =
toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
assert_eq!(
effective, project_config,
"Project config should take precedence over global"
);
assert_ne!(
effective, global_config,
"Should not return global config when project config exists"
);
}
#[test]
fn test_us003_get_effective_config_does_not_create_project_config() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config::default();
let global_path = config_dir.join("config.toml");
fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let project_config_path = project_dir.join("config.toml");
assert!(
!project_config_path.exists(),
"Project config should not exist before"
);
let effective_path = if project_config_path.exists() {
&project_config_path
} else {
&global_path
};
let _effective: Config =
toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
assert!(
!project_config_path.exists(),
"get_effective_config should NOT create project config"
);
}
#[test]
fn test_us003_project_config_roundtrip() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let original = Config {
review: false,
commit: true,
pull_request: false,
..Default::default()
};
let project_config_path = project_dir.join("config.toml");
let content = generate_config_with_comments(&original);
fs::write(&project_config_path, &content).unwrap();
let loaded: Config =
toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
assert_eq!(original, loaded, "Config should survive save/load cycle");
}
#[test]
fn test_us003_project_config_handles_partial_config() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
let project_dir = config_dir.join("test-project");
fs::create_dir_all(&project_dir).unwrap();
let project_config_path = project_dir.join("config.toml");
let partial_content = r#"
# Partial project config
review = false
"#;
fs::write(&project_config_path, partial_content).unwrap();
let loaded: Config =
toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
assert!(!loaded.review, "review should be false as specified");
assert!(loaded.commit, "missing commit should default to true");
assert!(
loaded.pull_request,
"missing pull_request should default to true"
);
}
#[test]
fn test_us003_inheritance_simulation_with_temp_dirs() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config {
review: true,
commit: false,
pull_request: false,
..Default::default()
};
let global_content = generate_config_with_comments(&global_config);
let global_path = config_dir.join("config.toml");
fs::write(&global_path, &global_content).unwrap();
let project_dir = config_dir.join("test-project");
fs::create_dir_all(project_dir.join("spec")).unwrap();
fs::create_dir_all(project_dir.join("runs")).unwrap();
let project_config_path = project_dir.join("config.toml");
assert!(!project_config_path.exists());
fs::write(&project_config_path, &global_content).unwrap();
assert!(project_config_path.exists());
let loaded: Config =
toml::from_str(&fs::read_to_string(&project_config_path).unwrap()).unwrap();
assert_eq!(
loaded, global_config,
"Project config should inherit from global"
);
let project_content = fs::read_to_string(&project_config_path).unwrap();
assert!(project_content.contains("# Autom8 Configuration"));
assert!(project_content.contains("# Review state"));
}
#[test]
fn test_us003_project_config_override_simulation() {
let temp_dir = TempDir::new().unwrap();
let config_dir = temp_dir.path().join(".config").join("autom8");
fs::create_dir_all(&config_dir).unwrap();
let global_config = Config {
review: true,
commit: true,
pull_request: true,
..Default::default()
};
let global_path = config_dir.join("config.toml");
fs::write(&global_path, generate_config_with_comments(&global_config)).unwrap();
let project_config = Config {
review: false,
commit: true,
pull_request: false,
..Default::default()
};
let project_dir = config_dir.join("my-project");
fs::create_dir_all(&project_dir).unwrap();
let project_path = project_dir.join("config.toml");
fs::write(
&project_path,
generate_config_with_comments(&project_config),
)
.unwrap();
let effective_path = if project_path.exists() {
&project_path
} else {
&global_path
};
let effective: Config =
toml::from_str(&fs::read_to_string(effective_path).unwrap()).unwrap();
assert_eq!(
effective, project_config,
"Project config should take precedence"
);
assert_ne!(effective.review, global_config.review);
assert_ne!(effective.pull_request, global_config.pull_request);
}
#[test]
fn test_us004_validate_config_accepts_default_config() {
let config = Config::default();
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_us004_validate_config_accepts_all_true() {
let config = Config {
review: true,
commit: true,
pull_request: true,
..Default::default()
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_us004_validate_config_accepts_all_false() {
let config = Config {
review: false,
commit: false,
pull_request: false,
..Default::default()
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_us004_validate_config_accepts_commit_true_pr_false() {
let config = Config {
review: true,
commit: true,
pull_request: false,
..Default::default()
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_us004_validate_config_accepts_commit_false_pr_false() {
let config = Config {
review: true,
commit: false,
pull_request: false,
..Default::default()
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_us004_validate_config_rejects_pr_true_commit_false() {
let config = Config {
review: true,
commit: false,
pull_request: true,
..Default::default()
};
let result = validate_config(&config);
assert!(result.is_err());
assert_eq!(result.unwrap_err(), ConfigError::PullRequestWithoutCommit);
}
#[test]
fn test_us004_config_error_message_is_actionable() {
let error = ConfigError::PullRequestWithoutCommit;
let message = error.to_string();
assert_eq!(
message,
"Cannot create pull request without commits. \
Either set `commit = true` or set `pull_request = false`"
);
}
#[test]
fn test_us004_config_error_implements_error_trait() {
let error = ConfigError::PullRequestWithoutCommit;
let _: &dyn std::error::Error = &error;
}
#[test]
fn test_us004_config_error_debug_format() {
let error = ConfigError::PullRequestWithoutCommit;
let debug_str = format!("{:?}", error);
assert!(debug_str.contains("PullRequestWithoutCommit"));
}
#[test]
fn test_us004_config_error_clone() {
let error = ConfigError::PullRequestWithoutCommit;
let cloned = error.clone();
assert_eq!(error, cloned);
}
#[test]
fn test_us004_validate_config_accepts_review_false_with_valid_pr_commit() {
let config = Config {
review: false,
commit: true,
pull_request: true,
..Default::default()
};
assert!(validate_config(&config).is_ok());
}
#[test]
fn test_us004_validate_config_all_combinations() {
let combinations = [
(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), ];
for (review, commit, pull_request, should_be_valid) in combinations {
let config = Config {
review,
commit,
pull_request,
..Default::default()
};
let result = validate_config(&config);
assert_eq!(
result.is_ok(),
should_be_valid,
"Config (review={}, commit={}, pull_request={}) expected valid={}, got valid={}",
review,
commit,
pull_request,
should_be_valid,
result.is_ok()
);
}
}
#[test]
fn test_us004_get_effective_config_validates_before_returning() {
let invalid_config = Config {
review: true,
commit: false,
pull_request: true,
..Default::default()
};
let validation_result = validate_config(&invalid_config);
assert!(validation_result.is_err());
let error = validation_result.unwrap_err();
let message = error.to_string();
assert!(message.contains("commit = true"));
assert!(message.contains("pull_request = false"));
}
#[test]
fn test_us004_validation_integration_with_autom8_error() {
let config_error = ConfigError::PullRequestWithoutCommit;
let autom8_error = Autom8Error::Config(config_error.to_string());
let error_string = format!("{}", autom8_error);
assert!(error_string.contains("Cannot create pull request without commits"));
}
#[test]
fn test_config_with_use_tui_field_still_parses() {
let toml_str = r#"
review = true
commit = true
pull_request = true
use_tui = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.review);
assert!(config.commit);
assert!(config.pull_request);
}
#[test]
fn test_worktree_config_defaults_to_true() {
let config = Config::default();
assert!(config.worktree, "worktree should default to true");
}
#[test]
fn test_worktree_config_can_be_enabled() {
let toml_str = r#"
worktree = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
config.worktree,
"worktree should be true when set in config"
);
}
#[test]
fn test_worktree_config_missing_defaults_to_true() {
let toml_str = r#"
review = true
commit = true
pull_request = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
config.worktree,
"missing worktree field should default to true"
);
}
#[test]
fn test_worktree_config_explicit_false() {
let toml_str = r#"
worktree = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
!config.worktree,
"explicit worktree = false should be respected"
);
}
#[test]
fn test_worktree_config_with_all_other_fields() {
let toml_str = r#"
review = false
commit = true
pull_request = false
worktree = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(!config.review);
assert!(config.commit);
assert!(!config.pull_request);
assert!(config.worktree);
}
#[test]
fn test_worktree_config_documentation_note_in_generated_comments() {
let config = Config::default();
let content = generate_config_with_comments(&config);
assert!(
content.contains("Requires a git repository"),
"config comments should document git repo requirement"
);
}
#[test]
fn test_worktree_cleanup_config_defaults_to_false() {
let config = Config::default();
assert!(
!config.worktree_cleanup,
"worktree_cleanup should default to false for backward compatibility"
);
}
#[test]
fn test_worktree_cleanup_config_can_be_enabled() {
let toml_str = r#"
worktree_cleanup = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
config.worktree_cleanup,
"worktree_cleanup should be true when set in config"
);
}
#[test]
fn test_worktree_cleanup_config_missing_defaults_to_false() {
let toml_str = r#"
review = true
commit = true
worktree = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
!config.worktree_cleanup,
"missing worktree_cleanup field should default to false"
);
}
#[test]
fn test_worktree_cleanup_config_explicit_false() {
let toml_str = r#"
worktree_cleanup = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
!config.worktree_cleanup,
"explicit worktree_cleanup = false should be respected"
);
}
#[test]
fn test_worktree_cleanup_config_with_all_worktree_fields() {
let toml_str = r#"
worktree = true
worktree_path_pattern = "{repo}-test-{branch}"
worktree_cleanup = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.worktree);
assert_eq!(config.worktree_path_pattern, "{repo}-test-{branch}");
assert!(config.worktree_cleanup);
}
#[test]
fn test_worktree_cleanup_in_generated_comments() {
let config = Config {
worktree_cleanup: true,
..Default::default()
};
let content = generate_config_with_comments(&config);
assert!(
content.contains("worktree_cleanup = true"),
"generated config should include worktree_cleanup setting"
);
assert!(
content.contains("successful completion"),
"config comments should document cleanup behavior"
);
}
#[test]
fn test_worktree_cleanup_in_default_config_with_comments() {
assert!(
DEFAULT_CONFIG_WITH_COMMENTS.contains("worktree_cleanup"),
"DEFAULT_CONFIG_WITH_COMMENTS should include worktree_cleanup"
);
assert!(
DEFAULT_CONFIG_WITH_COMMENTS.contains("worktree_cleanup = false"),
"DEFAULT_CONFIG_WITH_COMMENTS should have worktree_cleanup = false"
);
}
#[test]
fn test_worktree_cleanup_serialization_roundtrip() {
let config = Config {
worktree: true,
worktree_cleanup: true,
..Default::default()
};
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("worktree_cleanup = true"));
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.worktree_cleanup, config.worktree_cleanup);
}
#[test]
fn test_pull_request_draft_config_defaults_to_false() {
let config = Config::default();
assert!(
!config.pull_request_draft,
"pull_request_draft should default to false for backward compatibility"
);
}
#[test]
fn test_pull_request_draft_config_can_be_enabled() {
let toml_str = r#"
pull_request_draft = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
config.pull_request_draft,
"pull_request_draft should be true when set in config"
);
}
#[test]
fn test_pull_request_draft_config_missing_defaults_to_false() {
let toml_str = r#"
review = true
commit = true
pull_request = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
!config.pull_request_draft,
"missing pull_request_draft field should default to false"
);
}
#[test]
fn test_pull_request_draft_config_explicit_false() {
let toml_str = r#"
pull_request_draft = false
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(
!config.pull_request_draft,
"explicit pull_request_draft = false should be respected"
);
}
#[test]
fn test_pull_request_draft_config_with_all_pr_fields() {
let toml_str = r#"
pull_request = true
pull_request_draft = true
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert!(config.pull_request);
assert!(config.pull_request_draft);
}
#[test]
fn test_pull_request_draft_in_generated_comments() {
let config = Config {
pull_request_draft: true,
..Default::default()
};
let content = generate_config_with_comments(&config);
assert!(
content.contains("pull_request_draft = true"),
"generated config should include pull_request_draft setting"
);
assert!(
content.contains("draft mode"),
"config comments should document draft mode behavior"
);
}
#[test]
fn test_pull_request_draft_in_default_config_with_comments() {
assert!(
DEFAULT_CONFIG_WITH_COMMENTS.contains("pull_request_draft"),
"DEFAULT_CONFIG_WITH_COMMENTS should include pull_request_draft"
);
assert!(
DEFAULT_CONFIG_WITH_COMMENTS.contains("pull_request_draft = false"),
"DEFAULT_CONFIG_WITH_COMMENTS should have pull_request_draft = false"
);
}
#[test]
fn test_pull_request_draft_serialization_roundtrip() {
let config = Config {
pull_request: true,
pull_request_draft: true,
..Default::default()
};
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("pull_request_draft = true"));
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.pull_request_draft, config.pull_request_draft);
}
#[test]
fn test_us001_spec_summary_is_active_field() {
let spec_active = SpecSummary {
filename: "spec-active.json".to_string(),
path: PathBuf::from("/test/spec-active.json"),
project_name: "test".to_string(),
branch_name: "feature/active".to_string(),
description: "Active spec".to_string(),
stories: vec![],
completed_count: 0,
total_count: 0,
is_active: true,
};
let spec_inactive = SpecSummary {
filename: "spec-inactive.json".to_string(),
path: PathBuf::from("/test/spec-inactive.json"),
project_name: "test".to_string(),
branch_name: "feature/inactive".to_string(),
description: "Inactive spec".to_string(),
stories: vec![],
completed_count: 0,
total_count: 0,
is_active: false,
};
assert!(spec_active.is_active);
assert!(!spec_inactive.is_active);
}
fn compute_last_run_timestamp(
has_active_run: bool,
run_started_at: Option<chrono::DateTime<chrono::Utc>>,
run_finished_at: Option<chrono::DateTime<chrono::Utc>>,
archived_started_at: Option<chrono::DateTime<chrono::Utc>>,
archived_finished_at: Option<chrono::DateTime<chrono::Utc>>,
) -> Option<chrono::DateTime<chrono::Utc>> {
if has_active_run {
run_started_at
} else {
run_finished_at
.or(run_started_at)
.or(archived_finished_at)
.or(archived_started_at)
}
}
#[test]
fn test_us004_last_run_date_active_run_uses_started_at() {
use chrono::{Duration, Utc};
let started_at = Utc::now() - Duration::minutes(30);
let finished_at = None;
let result = compute_last_run_timestamp(
true, Some(started_at), finished_at, None, None, );
assert_eq!(result, Some(started_at));
}
#[test]
fn test_us004_last_run_date_completed_run_uses_finished_at() {
use chrono::{Duration, Utc};
let started_at = Utc::now() - Duration::hours(2);
let finished_at = Utc::now() - Duration::minutes(30);
let result = compute_last_run_timestamp(
false, Some(started_at), Some(finished_at), None, None, );
assert_eq!(result, Some(finished_at));
}
#[test]
fn test_us004_last_run_date_completed_run_fallback_to_started_at() {
use chrono::{Duration, Utc};
let started_at = Utc::now() - Duration::hours(2);
let result = compute_last_run_timestamp(
false, Some(started_at), None, None, None, );
assert_eq!(result, Some(started_at));
}
#[test]
fn test_us004_last_run_date_archived_run_uses_finished_at() {
use chrono::{Duration, Utc};
let archived_started_at = Utc::now() - Duration::days(1);
let archived_finished_at = Utc::now() - Duration::hours(23);
let result = compute_last_run_timestamp(
false, None, None, Some(archived_started_at), Some(archived_finished_at), );
assert_eq!(result, Some(archived_finished_at));
}
#[test]
fn test_us004_last_run_date_no_runs_returns_none() {
let result = compute_last_run_timestamp(
false, None, None, None, None, );
assert_eq!(result, None);
}
#[test]
fn test_us004_last_run_date_prefers_current_over_archived() {
use chrono::{Duration, Utc};
let current_started_at = Utc::now() - Duration::hours(1);
let current_finished_at = Utc::now() - Duration::minutes(30);
let archived_started_at = Utc::now() - Duration::days(7);
let archived_finished_at = Utc::now() - Duration::days(7) + Duration::hours(2);
let result = compute_last_run_timestamp(
false, Some(current_started_at), Some(current_finished_at), Some(archived_started_at), Some(archived_finished_at), );
assert_eq!(result, Some(current_finished_at));
}
}