1use 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
18pub type ConfigValidator = Arc<dyn Fn(&str) -> std::result::Result<(), String> + Send + Sync>;
23
24#[derive(Default)]
25pub struct SelfModTool {
26 config_validator: Option<ConfigValidator>,
29}
30
31impl SelfModTool {
32 pub fn new() -> Self {
34 Self::default()
35 }
36
37 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
54fn 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 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 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
117fn 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 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
143fn 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
160async 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 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
191fn 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
203fn 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
214fn git_dirty_files(root: &Path) -> Vec<String> {
216 let mut files = Vec::new();
217 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 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 "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 "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 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 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 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 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 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 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, ¤t)?;
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 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 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 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 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 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 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 let (check_ok, check_out) = run_cargo(&root, &["check", "--workspace"], 60).await;
595 if !check_ok {
596 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 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 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 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 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
709fn 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 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 let workspace = PathBuf::from("/workspace");
768 if workspace.join("Cargo.toml").exists() {
769 assert!(is_aegis_workspace(&workspace));
770 }
771 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 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 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}