Skip to main content

aegis_tools/
selfmod.rs

1//! `selfmod` tool — unified entry point for aegis self-modification.
2//!
3//! Two layers:
4//! - Layer 1 (config): modify ~/.aegis/ files (SOUL.md, filters.toml, config.toml,
5//!   tools.d/, strategies/, skills/, widgets.json, peers.json). All users.
6//! - Layer 2 (source): locate/build/test/verify/rollback aegis source code.
7//!   Only available when source checkout is present.
8
9use crate::registry::{Tool, ToolContext};
10use crate::tools::CheckpointManager;
11use anyhow::Result;
12use async_trait::async_trait;
13use serde_json::{json, Value};
14use std::path::{Path, PathBuf};
15use std::process::Stdio;
16use std::sync::Arc;
17
18/// Validates candidate `config.toml` content before it is persisted. Returns
19/// `Err(reason)` if the content would not load as a valid config. Injected from
20/// the binary layer (which can see `aegis-core::Config`) — `aegis-tools` cannot
21/// depend on `aegis-core` (that would be a dependency cycle).
22pub type ConfigValidator = Arc<dyn Fn(&str) -> std::result::Result<(), String> + Send + Sync>;
23
24#[derive(Default)]
25pub struct SelfModTool {
26    /// Optional write guard for `config.toml`. When set, `action_config` refuses
27    /// to persist content that fails validation (leaving the file untouched).
28    config_validator: Option<ConfigValidator>,
29}
30
31impl SelfModTool {
32    /// Construct without a config write guard (validation is skipped).
33    pub fn new() -> Self {
34        Self::default()
35    }
36
37    /// Construct with a config write guard. Prefer this at the binary layer so
38    /// self-modifications that would corrupt `config.toml` are rejected.
39    pub fn with_config_validator(validator: ConfigValidator) -> Self {
40        Self {
41            config_validator: Some(validator),
42        }
43    }
44}
45
46fn is_aegis_workspace(path: &Path) -> bool {
47    path.join("Cargo.toml").exists() && path.join("crates").is_dir()
48}
49
50fn config_dir() -> PathBuf {
51    aegis_types::paths::config_dir()
52}
53
54/// Discover aegis source root. Priority:
55/// 1. AEGIS_SOURCE_DIR env var
56/// 2. Walk CWD ancestors looking for aegis workspace (Cargo.toml + crates/)
57/// 3. ~/src/aegis or ~/.aegis/source (convention paths)
58/// 4. /workspace (dev container)
59fn find_source_root() -> Option<PathBuf> {
60    if let Ok(dir) = std::env::var("AEGIS_SOURCE_DIR") {
61        let p = PathBuf::from(dir);
62        if is_aegis_workspace(&p) {
63            return Some(p);
64        }
65    }
66
67    // Walk CWD ancestors (covers "user is developing aegis and CWD is inside repo")
68    if let Ok(cwd) = std::env::current_dir() {
69        let mut dir = cwd.as_path();
70        loop {
71            if is_aegis_workspace(dir) {
72                return Some(dir.to_path_buf());
73            }
74            match dir.parent() {
75                Some(parent) => dir = parent,
76                None => break,
77            }
78        }
79    }
80
81    // Convention paths
82    let home = dirs_next::home_dir().unwrap_or_default();
83    for candidate in [
84        home.join("src/aegis"),
85        home.join(".aegis/source"),
86        PathBuf::from("/workspace"),
87    ] {
88        if is_aegis_workspace(&candidate) {
89            return Some(candidate);
90        }
91    }
92    None
93}
94
95fn coerce_config_value(existing: Option<&toml::Value>, value: &str) -> toml::Value {
96    match existing {
97        Some(toml::Value::String(_)) => toml::Value::String(value.to_string()),
98        Some(toml::Value::Boolean(_)) if value == "true" || value == "false" => {
99            toml::Value::Boolean(value == "true")
100        }
101        Some(toml::Value::Integer(_)) => match value.parse::<i64>() {
102            Ok(n) => toml::Value::Integer(n),
103            Err(_) => toml::Value::String(value.to_string()),
104        },
105        _ => {
106            if value == "true" || value == "false" {
107                toml::Value::Boolean(value == "true")
108            } else if let Ok(n) = value.parse::<i64>() {
109                toml::Value::Integer(n)
110            } else {
111                toml::Value::String(value.to_string())
112            }
113        }
114    }
115}
116
117/// Create a backup of a config file before modification.
118fn backup_config_file(path: &Path) -> Result<()> {
119    if !path.exists() {
120        return Ok(());
121    }
122    let backup_dir = config_dir().join("backups");
123    std::fs::create_dir_all(&backup_dir)?;
124    let ts = chrono::Utc::now().format("%Y%m%d-%H%M%S").to_string();
125    let fname = path.file_name().unwrap_or_default().to_string_lossy();
126    let backup_path = backup_dir.join(format!("{fname}.{ts}"));
127    std::fs::copy(path, &backup_path)?;
128
129    // Keep max 5 backups per file
130    let prefix = format!("{fname}.");
131    let mut backups: Vec<_> = std::fs::read_dir(&backup_dir)?
132        .flatten()
133        .filter(|e| e.file_name().to_string_lossy().starts_with(&prefix))
134        .collect();
135    backups.sort_by_key(|e| e.file_name());
136    while backups.len() > 5 {
137        let _ = std::fs::remove_file(backups[0].path());
138        backups.remove(0);
139    }
140    Ok(())
141}
142
143/// List crates in source workspace.
144fn list_crates(root: &Path) -> Vec<String> {
145    let mut crates = Vec::new();
146    for dir in ["crates", "bins"] {
147        let base = root.join(dir);
148        if let Ok(entries) = std::fs::read_dir(&base) {
149            for entry in entries.flatten() {
150                if entry.path().join("Cargo.toml").exists() {
151                    crates.push(format!("{}/{}", dir, entry.file_name().to_string_lossy()));
152                }
153            }
154        }
155    }
156    crates.sort();
157    crates
158}
159
160/// Run a cargo command with timeout, returning (success, output).
161async fn run_cargo(root: &Path, args: &[&str], timeout_secs: u64) -> (bool, String) {
162    let result = tokio::time::timeout(
163        std::time::Duration::from_secs(timeout_secs),
164        tokio::process::Command::new("cargo")
165            .args(args)
166            .current_dir(root)
167            .stdout(Stdio::piped())
168            .stderr(Stdio::piped())
169            .output(),
170    )
171    .await;
172
173    match result {
174        Ok(Ok(output)) => {
175            let stdout = String::from_utf8_lossy(&output.stdout);
176            let stderr = String::from_utf8_lossy(&output.stderr);
177            let combined = if stdout.is_empty() {
178                stderr.to_string()
179            } else {
180                format!("{stdout}\n{stderr}")
181            };
182            // Truncate to first 80 lines
183            let truncated: String = combined.lines().take(80).collect::<Vec<_>>().join("\n");
184            (output.status.success(), truncated)
185        }
186        Ok(Err(e)) => (false, format!("Failed to run cargo: {e}")),
187        Err(_) => (false, format!("Timeout after {timeout_secs}s")),
188    }
189}
190
191/// Check if git is available and we're in a repo.
192fn has_git(root: &Path) -> bool {
193    std::process::Command::new("git")
194        .args(["rev-parse", "--git-dir"])
195        .current_dir(root)
196        .stdout(Stdio::null())
197        .stderr(Stdio::null())
198        .status()
199        .map(|s| s.success())
200        .unwrap_or(false)
201}
202
203/// Get current git short hash.
204fn git_short_hash(root: &Path) -> Option<String> {
205    std::process::Command::new("git")
206        .args(["rev-parse", "--short", "HEAD"])
207        .current_dir(root)
208        .output()
209        .ok()
210        .filter(|o| o.status.success())
211        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
212}
213
214/// Get dirty file list (modified + untracked in src dirs).
215fn git_dirty_files(root: &Path) -> Vec<String> {
216    let mut files = Vec::new();
217    // Modified tracked files
218    if let Ok(o) = std::process::Command::new("git")
219        .args(["diff", "--name-only"])
220        .current_dir(root)
221        .output()
222    {
223        for line in String::from_utf8_lossy(&o.stdout).lines() {
224            if !line.is_empty() {
225                files.push(line.to_string());
226            }
227        }
228    }
229    // Staged but uncommitted
230    if let Ok(o) = std::process::Command::new("git")
231        .args(["diff", "--name-only", "--cached"])
232        .current_dir(root)
233        .output()
234    {
235        for line in String::from_utf8_lossy(&o.stdout).lines() {
236            if !line.is_empty() && !files.contains(&line.to_string()) {
237                files.push(line.to_string());
238            }
239        }
240    }
241    files
242}
243
244#[async_trait]
245impl Tool for SelfModTool {
246    fn name(&self) -> &str {
247        "selfmod"
248    }
249
250    fn description(&self) -> &str {
251        "Modify aegis's own behavior and source code. Layer 1 actions (config/soul/filter/script_tool) \
252         work for all users — edit ~/.aegis/ config files. Layer 2 actions (locate/build/test/verify/status/rollback) \
253         require aegis source checkout for source-level changes."
254    }
255
256    fn parameters(&self) -> Value {
257        json!({
258            "type": "object",
259            "properties": {
260                "action": {
261                    "type": "string",
262                    "enum": ["config", "soul", "filter", "script_tool", "locate", "build", "test", "verify", "status", "rollback"],
263                    "description": "Action to perform. config/soul/filter/script_tool = Layer 1 (all users). locate/build/test/verify/status/rollback = Layer 2 (source required)."
264                },
265                "target": {
266                    "type": "string",
267                    "description": "For locate/build/test: crate name (e.g. 'aegis-tools'). For config: config key path (e.g. 'upgrade.auto_apply'). For script_tool: tool name."
268                },
269                "value": {
270                    "type": "string",
271                    "description": "For config: new value to set. For soul: full new SOUL.md content (or 'read' to read current). For filter: TOML content to append. For script_tool: TOML content of the tool definition."
272                },
273                "message": {
274                    "type": "string",
275                    "description": "Commit message (for verify action)"
276                }
277            },
278            "required": ["action"]
279        })
280    }
281
282    async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
283        let action = args["action"].as_str().unwrap_or("");
284
285        match action {
286            // ═══ Layer 1: Config ═══
287            "config" => self.action_config(&args).await,
288            "soul" => self.action_soul(&args).await,
289            "filter" => self.action_filter(&args).await,
290            "script_tool" => self.action_script_tool(&args).await,
291
292            // ═══ Layer 2: Source ═══
293            "locate" => self.action_locate(&args).await,
294            "build" => self.action_build(&args).await,
295            "test" => self.action_test(&args).await,
296            "verify" => self.action_verify(&args, ctx).await,
297            "status" => self.action_status().await,
298            "rollback" => self.action_rollback().await,
299
300            _ => Ok(format!("Unknown action: '{action}'. Available: config, soul, filter, script_tool, locate, build, test, verify, status, rollback")),
301        }
302    }
303}
304
305impl SelfModTool {
306    // ─── Layer 1 Actions ───
307
308    async fn action_config(&self, args: &Value) -> Result<String> {
309        let config_path = config_dir().join("config.toml");
310        let key = args["target"].as_str().unwrap_or("");
311        let value = args["value"].as_str().unwrap_or("");
312
313        if key.is_empty() {
314            // Read mode: return current config
315            let content = std::fs::read_to_string(&config_path).unwrap_or_default();
316            if content.is_empty() {
317                return Ok("No config.toml found. Use with target='section.key' and value='...' to set.".into());
318            }
319            return Ok(format!("Current config (~/.aegis/config.toml):\n```toml\n{content}\n```"));
320        }
321
322        if value.is_empty() {
323            // Read specific key
324            let content = std::fs::read_to_string(&config_path).unwrap_or_default();
325            let table: toml::Table = content.parse().unwrap_or_default();
326            let parts: Vec<&str> = key.split('.').collect();
327            let result = match parts.as_slice() {
328                [section, field] => table
329                    .get(*section)
330                    .and_then(|s| s.get(*field))
331                    .map(|v| v.to_string())
332                    .unwrap_or_else(|| "(not set)".to_string()),
333                [field] => table
334                    .get(*field)
335                    .map(|v| v.to_string())
336                    .unwrap_or_else(|| "(not set)".to_string()),
337                _ => "(invalid key path)".to_string(),
338            };
339            return Ok(format!("{key} = {result}"));
340        }
341
342        // Write mode
343        backup_config_file(&config_path)?;
344        let content = std::fs::read_to_string(&config_path).unwrap_or_default();
345        let mut table: toml::Table = content.parse().unwrap_or_default();
346
347        let parts: Vec<&str> = key.split('.').collect();
348
349        // Type-aware coercion. If the key already exists with a concrete type,
350        // preserve that type (so a String-valued field like `upgrade.auto_apply`
351        // is never silently rewritten as a bool/int just because its new value
352        // *looks* boolean/numeric). Only fall back to value-shape inference for
353        // brand-new keys.
354        let existing = match parts.as_slice() {
355            [section, field] => table.get(*section).and_then(|s| s.get(*field)),
356            [field] => table.get(*field),
357            _ => None,
358        };
359        let toml_value = coerce_config_value(existing, value);
360
361        match parts.as_slice() {
362            [section, field] => {
363                let section_table = table
364                    .entry(section.to_string())
365                    .or_insert_with(|| toml::Value::Table(toml::Table::new()));
366                if let Some(t) = section_table.as_table_mut() {
367                    t.insert(field.to_string(), toml_value);
368                }
369            }
370            [field] => {
371                table.insert(field.to_string(), toml_value);
372            }
373            _ => return Ok("Invalid key path. Use 'section.key' or 'key'.".into()),
374        }
375
376        let new_content = toml::to_string_pretty(&table)?;
377
378        // Write guard: never persist a config the daemon couldn't load back.
379        // Catches wrong types *and* nonsense enum values (e.g. auto_apply="off").
380        if let Some(validate) = &self.config_validator {
381            if let Err(e) = validate(&new_content) {
382                return Ok(format!(
383                    "Refused to write: setting `{key} = {value}` would produce a config \
384                     that can't be loaded, so config.toml was left unchanged.\nReason: {e}"
385                ));
386            }
387        }
388
389        std::fs::write(&config_path, &new_content)?;
390        Ok(format!("Set {key} = {value} in config.toml. Backup saved."))
391    }
392
393    async fn action_soul(&self, args: &Value) -> Result<String> {
394        let soul_path = config_dir().join("SOUL.md");
395        let value = args["value"].as_str().unwrap_or("read");
396
397        if value == "read" {
398            let content = std::fs::read_to_string(&soul_path).unwrap_or_else(|_| "(no SOUL.md found)".into());
399            return Ok(format!("Current SOUL.md:\n\n{content}"));
400        }
401
402        backup_config_file(&soul_path)?;
403        std::fs::create_dir_all(config_dir())?;
404        std::fs::write(&soul_path, value)?;
405        Ok("SOUL.md updated. Changes take effect next turn.".into())
406    }
407
408    async fn action_filter(&self, args: &Value) -> Result<String> {
409        let filter_path = config_dir().join("filters.toml");
410        let value = args["value"].as_str().unwrap_or("read");
411
412        if value == "read" {
413            let content = std::fs::read_to_string(&filter_path).unwrap_or_else(|_| "(no filters.toml found)".into());
414            return Ok(format!("Current filters.toml:\n```toml\n{content}\n```"));
415        }
416
417        backup_config_file(&filter_path)?;
418        let mut current = std::fs::read_to_string(&filter_path).unwrap_or_default();
419        if !current.is_empty() && !current.ends_with('\n') {
420            current.push('\n');
421        }
422        current.push_str(value);
423        current.push('\n');
424        std::fs::write(&filter_path, &current)?;
425        Ok("Filter rule appended to filters.toml. Takes effect next turn.".into())
426    }
427
428    async fn action_script_tool(&self, args: &Value) -> Result<String> {
429        let tools_dir = config_dir().join("tools.d");
430        let name = args["target"].as_str().unwrap_or("");
431        let value = args["value"].as_str().unwrap_or("");
432
433        if name.is_empty() && value.is_empty() {
434            // List existing script tools
435            let mut tools = Vec::new();
436            if let Ok(entries) = std::fs::read_dir(&tools_dir) {
437                for entry in entries.flatten() {
438                    let fname = entry.file_name().to_string_lossy().to_string();
439                    if fname.ends_with(".toml") {
440                        tools.push(fname.trim_end_matches(".toml").to_string());
441                    }
442                }
443            }
444            if tools.is_empty() {
445                return Ok("No script tools defined. Create one with target='name' and value='<toml content>'.".into());
446            }
447            tools.sort();
448            return Ok(format!("Script tools in ~/.aegis/tools.d/:\n{}", tools.iter().map(|t| format!("- {t}")).collect::<Vec<_>>().join("\n")));
449        }
450
451        if name.is_empty() {
452            return Ok("Error: 'target' (tool name) is required for script_tool action.".into());
453        }
454
455        if value == "read" {
456            let path = tools_dir.join(format!("{name}.toml"));
457            let content = std::fs::read_to_string(&path)
458                .unwrap_or_else(|_| format!("(tool '{name}' not found)"));
459            return Ok(content);
460        }
461
462        if value == "remove" {
463            let path = tools_dir.join(format!("{name}.toml"));
464            if path.exists() {
465                std::fs::remove_file(&path)?;
466                return Ok(format!("Removed script tool '{name}'."));
467            }
468            return Ok(format!("Tool '{name}' not found."));
469        }
470
471        // Create/update tool
472        std::fs::create_dir_all(&tools_dir)?;
473        let path = tools_dir.join(format!("{name}.toml"));
474        if path.exists() {
475            backup_config_file(&path)?;
476        }
477        std::fs::write(&path, value)?;
478        Ok(format!("Script tool '{name}' saved to ~/.aegis/tools.d/{name}.toml. Available next turn."))
479    }
480
481    // ─── Layer 2 Actions ───
482
483    async fn action_locate(&self, args: &Value) -> Result<String> {
484        let root = match find_source_root() {
485            Some(r) => r,
486            None => return Ok(
487                "Source not available. Set AEGIS_SOURCE_DIR or clone to ~/src/aegis.\n\
488                 For config-layer changes (SOUL.md, filters, script tools), use the config/soul/filter/script_tool actions instead."
489                    .into(),
490            ),
491        };
492
493        let target = args["target"].as_str().unwrap_or("");
494        if target.is_empty() {
495            let crates = list_crates(&root);
496            return Ok(format!(
497                "Source root: {}\nCrates ({}):\n{}",
498                root.display(),
499                crates.len(),
500                crates.iter().map(|c| format!("  {c}")).collect::<Vec<_>>().join("\n")
501            ));
502        }
503
504        // Find specific crate
505        for dir in ["crates", "bins"] {
506            let candidate = root.join(dir).join(target);
507            if candidate.exists() {
508                let files: Vec<String> = walkdir_simple(&candidate.join("src"));
509                return Ok(format!(
510                    "source_root: {}\ncrate_path: {}/{target}/src/\nfiles:\n{}",
511                    root.display(),
512                    dir,
513                    files.iter().map(|f| format!("  {f}")).collect::<Vec<_>>().join("\n")
514                ));
515            }
516        }
517        // Try partial match
518        let crates = list_crates(&root);
519        let matches: Vec<&String> = crates.iter().filter(|c| c.contains(target)).collect();
520        if matches.is_empty() {
521            Ok(format!("Crate '{target}' not found. Available: {}", crates.join(", ")))
522        } else {
523            Ok(format!("Partial matches for '{target}': {}", matches.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", ")))
524        }
525    }
526
527    async fn action_build(&self, args: &Value) -> Result<String> {
528        let root = match find_source_root() {
529            Some(r) => r,
530            None => return Ok("Source not available. Set AEGIS_SOURCE_DIR.".into()),
531        };
532
533        let target = args["target"].as_str().unwrap_or("");
534        let cargo_args = if target.is_empty() || target == "all" {
535            vec!["check", "--workspace"]
536        } else {
537            vec!["check", "-p", target]
538        };
539
540        let (success, output) = run_cargo(&root, &cargo_args, 60).await;
541        if success {
542            Ok(format!("cargo check: OK\n{output}"))
543        } else {
544            Ok(format!("cargo check: FAILED\n{output}"))
545        }
546    }
547
548    async fn action_test(&self, args: &Value) -> Result<String> {
549        let root = match find_source_root() {
550            Some(r) => r,
551            None => return Ok("Source not available. Set AEGIS_SOURCE_DIR.".into()),
552        };
553
554        let target = args["target"].as_str().unwrap_or("");
555        let cargo_args = if target.is_empty() || target == "all" {
556            vec!["test", "--workspace"]
557        } else {
558            vec!["test", "-p", target]
559        };
560
561        let (success, output) = run_cargo(&root, &cargo_args, 120).await;
562        if success {
563            Ok(format!("cargo test: OK\n{output}"))
564        } else {
565            Ok(format!("cargo test: FAILED\n{output}"))
566        }
567    }
568
569    async fn action_verify(&self, args: &Value, ctx: &ToolContext<'_>) -> Result<String> {
570        let root = match find_source_root() {
571            Some(r) => r,
572            None => return Ok("Source not available. Set AEGIS_SOURCE_DIR.".into()),
573        };
574
575        if !has_git(&root) {
576            return Ok("verify requires git. Source directory is not a git repository.".into());
577        }
578
579        let message = args["message"].as_str().unwrap_or("selfmod: auto-commit");
580
581        // Approval gate (unless yolo)
582        if !ctx.yolo {
583            let dirty = git_dirty_files(&root);
584            if dirty.len() > 5 {
585                return Ok(format!(
586                    "Too many changed files ({}). selfmod limits to 5 files per verify for safety. Changed: {}",
587                    dirty.len(),
588                    dirty.join(", ")
589                ));
590            }
591        }
592
593        // Step 1: cargo check
594        let (check_ok, check_out) = run_cargo(&root, &["check", "--workspace"], 60).await;
595        if !check_ok {
596            // Auto-rollback
597            let _ = std::process::Command::new("git")
598                .args(["checkout", "."])
599                .current_dir(&root)
600                .status();
601            return Ok(format!("Build FAILED — auto-rolled back.\n{check_out}"));
602        }
603
604        // Step 2: cargo test
605        let (test_ok, test_out) = run_cargo(&root, &["test", "--workspace"], 120).await;
606        if !test_ok {
607            let _ = std::process::Command::new("git")
608                .args(["checkout", "."])
609                .current_dir(&root)
610                .status();
611            return Ok(format!("Tests FAILED — auto-rolled back.\n{test_out}"));
612        }
613
614        // Step 3: commit
615        let _ = std::process::Command::new("git")
616            .args(["add", "-A"])
617            .current_dir(&root)
618            .status();
619        let commit_result = std::process::Command::new("git")
620            .args(["commit", "-m", message])
621            .current_dir(&root)
622            .output();
623
624        match commit_result {
625            Ok(output) if output.status.success() => {
626                let hash = git_short_hash(&root).unwrap_or_default();
627                Ok(format!("Build: OK. Tests: OK. Committed as {hash}.\nNote: source committed but binary not replaced. Use `aegis evolve` to deploy."))
628            }
629            Ok(output) => {
630                let err = String::from_utf8_lossy(&output.stderr);
631                Ok(format!("Build/test passed but commit failed: {err}"))
632            }
633            Err(e) => Ok(format!("Build/test passed but git commit error: {e}")),
634        }
635    }
636
637    async fn action_status(&self) -> Result<String> {
638        let root = match find_source_root() {
639            Some(r) => r,
640            None => {
641                // Show Layer 1 status only
642                let soul_exists = config_dir().join("SOUL.md").exists();
643                let filter_exists = config_dir().join("filters.toml").exists();
644                let tools_dir = config_dir().join("tools.d");
645                let tool_count = std::fs::read_dir(&tools_dir)
646                    .map(|rd| rd.flatten().count())
647                    .unwrap_or(0);
648                return Ok(format!(
649                    "Source: not available\nConfig dir: {}\nSOUL.md: {}\nfilters.toml: {}\nScript tools: {}",
650                    config_dir().display(),
651                    if soul_exists { "present" } else { "absent" },
652                    if filter_exists { "present" } else { "absent" },
653                    tool_count
654                ));
655            }
656        };
657
658        let dirty = git_dirty_files(&root);
659        let hash = git_short_hash(&root).unwrap_or_else(|| "unknown".into());
660        let soul_exists = config_dir().join("SOUL.md").exists();
661        let tools_dir = config_dir().join("tools.d");
662        let tool_count = std::fs::read_dir(&tools_dir)
663            .map(|rd| rd.flatten().count())
664            .unwrap_or(0);
665
666        let checkpoints = CheckpointManager::list().unwrap_or_default();
667        let recent_cp = checkpoints.first().map(|(name, _)| name.as_str()).unwrap_or("none");
668
669        Ok(format!(
670            "Source root: {}\nGit HEAD: {hash}\nDirty files: {}\nSOUL.md: {}\nScript tools: {}\nLast checkpoint: {recent_cp}{}",
671            root.display(),
672            if dirty.is_empty() { "clean".to_string() } else { format!("{}\n  {}", dirty.len(), dirty.join("\n  ")) },
673            if soul_exists { "present" } else { "absent" },
674            tool_count,
675            if dirty.is_empty() { String::new() } else { "\n\nUse 'rollback' to revert uncommitted changes.".to_string() }
676        ))
677    }
678
679    async fn action_rollback(&self) -> Result<String> {
680        let root = match find_source_root() {
681            Some(r) => r,
682            None => return Ok("Source not available. For config rollback, use checkpoints.".into()),
683        };
684
685        if !has_git(&root) {
686            return Ok("rollback requires git.".into());
687        }
688
689        let dirty = git_dirty_files(&root);
690        if dirty.is_empty() {
691            return Ok("Nothing to rollback — working directory is clean.".into());
692        }
693
694        let _ = std::process::Command::new("git")
695            .args(["checkout", "."])
696            .current_dir(&root)
697            .status();
698
699        // Clean untracked files in src directories only
700        let _ = std::process::Command::new("git")
701            .args(["clean", "-fd", "crates/", "bins/"])
702            .current_dir(&root)
703            .status();
704
705        Ok(format!("Rolled back {} dirty file(s). Working directory is clean.", dirty.len()))
706    }
707}
708
709/// Simple recursive file listing (no external crate needed).
710fn walkdir_simple(dir: &Path) -> Vec<String> {
711    let mut files = Vec::new();
712    if let Ok(entries) = std::fs::read_dir(dir) {
713        for entry in entries.flatten() {
714            let path = entry.path();
715            if path.is_dir() {
716                files.extend(walkdir_simple(&path));
717            } else {
718                let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
719                files.push(name);
720            }
721        }
722    }
723    files.sort();
724    files
725}
726
727#[cfg(test)]
728mod tests {
729    use super::*;
730
731    #[test]
732    fn test_coerce_config_value_preserves_existing_string_type() {
733        // Existing String field: even a boolean/numeric-looking value stays a
734        // string (this is the `upgrade.auto_apply = "false"` corruption guard).
735        let existing = toml::Value::String("ask".into());
736        assert_eq!(
737            coerce_config_value(Some(&existing), "false"),
738            toml::Value::String("false".into())
739        );
740        assert_eq!(
741            coerce_config_value(Some(&existing), "123"),
742            toml::Value::String("123".into())
743        );
744    }
745
746    #[test]
747    fn test_coerce_config_value_new_key_infers_shape() {
748        assert_eq!(coerce_config_value(None, "true"), toml::Value::Boolean(true));
749        assert_eq!(coerce_config_value(None, "42"), toml::Value::Integer(42));
750        assert_eq!(
751            coerce_config_value(None, "idle"),
752            toml::Value::String("idle".into())
753        );
754    }
755
756    #[test]
757    fn test_coerce_config_value_preserves_existing_bool_and_int() {
758        let b = toml::Value::Boolean(false);
759        assert_eq!(coerce_config_value(Some(&b), "true"), toml::Value::Boolean(true));
760        let i = toml::Value::Integer(1);
761        assert_eq!(coerce_config_value(Some(&i), "7"), toml::Value::Integer(7));
762    }
763
764    #[test]
765    fn test_is_aegis_workspace() {
766        // Current workspace should be detected
767        let workspace = PathBuf::from("/workspace");
768        if workspace.join("Cargo.toml").exists() {
769            assert!(is_aegis_workspace(&workspace));
770        }
771        // Random path should not
772        assert!(!is_aegis_workspace(Path::new("/tmp")));
773    }
774
775    #[test]
776    fn test_config_dir() {
777        let dir = config_dir();
778        assert!(dir.to_string_lossy().contains(".aegis"));
779    }
780
781    #[test]
782    fn test_find_source_root() {
783        // In CI/dev container, /workspace should be found
784        if PathBuf::from("/workspace").join("Cargo.toml").exists() {
785            assert!(find_source_root().is_some());
786        }
787    }
788
789    #[test]
790    fn test_list_crates() {
791        if let Some(root) = find_source_root() {
792            let crates = list_crates(&root);
793            assert!(!crates.is_empty());
794            assert!(crates.iter().any(|c| c.contains("aegis-tools")));
795        }
796    }
797
798    #[test]
799    fn test_backup_config_file_nonexistent() {
800        // Backing up a nonexistent file should be a no-op
801        let result = backup_config_file(Path::new("/tmp/nonexistent-aegis-test-file"));
802        assert!(result.is_ok());
803    }
804
805    #[test]
806    fn test_walkdir_simple_empty() {
807        let files = walkdir_simple(Path::new("/tmp/nonexistent-dir-aegis-test"));
808        assert!(files.is_empty());
809    }
810}