Skip to main content

mars_agents/cli/
init.rs

1//! `mars init [TARGET] [--link DIR...]` — scaffold a mars-managed directory with `mars.toml`.
2//!
3//! TARGET is a simple directory name (default: `.agents`), not a path.
4//! Creates `<cwd>/TARGET/mars.toml`. Use `--root` for explicit path control.
5//!
6//! Idempotent: re-running when already initialized is a no-op for init
7//! but still processes `--link` flags.
8
9use std::path::Path;
10
11use crate::config::{Config, Settings};
12use crate::error::{ConfigError, MarsError};
13
14use super::output;
15
16/// Arguments for `mars init`.
17#[derive(Debug, clap::Args)]
18pub struct InitArgs {
19    /// Directory name to create (default: .agents). Simple name, not a path.
20    pub target: Option<String>,
21
22    /// Directories to link after initialization. Repeatable.
23    #[arg(long, value_name = "DIR")]
24    pub link: Vec<String>,
25}
26
27/// Validate that a target is a simple directory name, not a path.
28fn validate_target(target: &str) -> Result<(), MarsError> {
29    if target.contains('/') || target.contains('\\') {
30        return Err(MarsError::Config(ConfigError::Invalid {
31            message: format!(
32                "`{target}` looks like a path — TARGET should be a directory name \
33                 like `.agents` or `.claude`. Use `--root` to specify an explicit path."
34            ),
35        }));
36    }
37    if target == "." || target == ".." || target.is_empty() {
38        return Err(MarsError::Config(ConfigError::Invalid {
39            message: format!(
40                "`{target}` is not a valid target name — use a directory name like `.agents` or `.claude`."
41            ),
42        }));
43    }
44    Ok(())
45}
46
47/// Run `mars init`.
48pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
49    // 1. Determine the managed root
50    let managed_root = if let Some(root) = explicit_root {
51        // --root flag: use directly (TARGET is ignored)
52        root.to_path_buf()
53    } else {
54        let target = args.target.as_deref().unwrap_or(".agents");
55        validate_target(target)?;
56        std::env::current_dir()?.join(target)
57    };
58
59    // 2. Idempotency check
60    let config_path = managed_root.join("mars.toml");
61    let already_initialized = config_path.exists();
62
63    if !already_initialized {
64        // 3. Create structure
65        std::fs::create_dir_all(&managed_root)?;
66        std::fs::create_dir_all(managed_root.join(".mars"))?;
67
68        let config = Config {
69            sources: indexmap::IndexMap::new(),
70            settings: Settings::default(),
71        };
72        crate::config::save(&managed_root, &config)?;
73        add_to_gitignore(&managed_root)?;
74
75        if !json {
76            output::print_success(&format!(
77                "initialized {} with mars.toml",
78                managed_root.display()
79            ));
80        }
81    } else {
82        // Already initialized — reconcile required structure
83        std::fs::create_dir_all(managed_root.join(".mars"))?;
84        add_to_gitignore(&managed_root)?;
85
86        if !json {
87            output::print_info(&format!("{} already initialized", managed_root.display()));
88        }
89    }
90
91    // 4. Process --link flags
92    if !args.link.is_empty() {
93        let ctx = super::MarsContext::new(managed_root.clone())?;
94        for link_target in &args.link {
95            let link_args = super::link::LinkArgs {
96                target: link_target.clone(),
97                unlink: false,
98                force: false,
99            };
100            super::link::run(&link_args, &ctx, json)?;
101        }
102    }
103
104    if json {
105        output::print_json(&serde_json::json!({
106            "ok": true,
107            "path": managed_root.to_string_lossy(),
108            "already_initialized": already_initialized,
109            "links": args.link,
110        }));
111    }
112
113    Ok(0)
114}
115
116/// Add `.mars/` to `.gitignore` in the agents directory if not already present.
117fn add_to_gitignore(agents_dir: &Path) -> Result<(), MarsError> {
118    let gitignore_path = agents_dir.join(".gitignore");
119    let entry = ".mars/";
120
121    if gitignore_path.exists() {
122        let content = std::fs::read_to_string(&gitignore_path)?;
123        if content.lines().any(|line| line.trim() == entry) {
124            return Ok(());
125        }
126        // Append
127        let mut new_content = content;
128        if !new_content.ends_with('\n') && !new_content.is_empty() {
129            new_content.push('\n');
130        }
131        new_content.push_str(entry);
132        new_content.push('\n');
133        crate::fs::atomic_write(&gitignore_path, new_content.as_bytes())?;
134    } else {
135        crate::fs::atomic_write(&gitignore_path, format!("{entry}\n").as_bytes())?;
136    }
137
138    Ok(())
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use tempfile::TempDir;
145
146    #[test]
147    fn validate_target_accepts_simple_names() {
148        assert!(validate_target(".agents").is_ok());
149        assert!(validate_target(".claude").is_ok());
150        assert!(validate_target("my-agents").is_ok());
151    }
152
153    #[test]
154    fn validate_target_rejects_paths() {
155        assert!(validate_target("./foo").is_err());
156        assert!(validate_target("foo/bar").is_err());
157        assert!(validate_target("/absolute/path").is_err());
158    }
159
160    #[test]
161    fn validate_target_rejects_dots() {
162        assert!(validate_target(".").is_err());
163        assert!(validate_target("..").is_err());
164    }
165
166    #[test]
167    fn validate_target_rejects_empty() {
168        assert!(validate_target("").is_err());
169    }
170
171    #[test]
172    fn init_creates_agents_toml() {
173        let dir = TempDir::new().unwrap();
174        let agents_dir = dir.path().join(".agents");
175
176        let args = InitArgs {
177            target: None,
178            link: vec![],
179        };
180
181        // We can't easily test run() because it uses current_dir(),
182        // but we can test with --root equivalent
183        std::fs::create_dir_all(&agents_dir).unwrap();
184        let config = Config {
185            sources: indexmap::IndexMap::new(),
186            settings: Settings::default(),
187        };
188        crate::config::save(&agents_dir, &config).unwrap();
189
190        // Verify the file was created correctly
191        assert!(agents_dir.join("mars.toml").exists());
192        let _ = args; // suppress unused warning
193    }
194
195    #[test]
196    fn add_to_gitignore_creates_file() {
197        let dir = TempDir::new().unwrap();
198        let agents_dir = dir.path().join(".agents");
199        std::fs::create_dir_all(&agents_dir).unwrap();
200
201        add_to_gitignore(&agents_dir).unwrap();
202
203        let content = std::fs::read_to_string(agents_dir.join(".gitignore")).unwrap();
204        assert!(content.contains(".mars/"));
205    }
206
207    #[test]
208    fn add_to_gitignore_idempotent() {
209        let dir = TempDir::new().unwrap();
210        let agents_dir = dir.path().join(".agents");
211        std::fs::create_dir_all(&agents_dir).unwrap();
212
213        add_to_gitignore(&agents_dir).unwrap();
214        add_to_gitignore(&agents_dir).unwrap();
215
216        let content = std::fs::read_to_string(agents_dir.join(".gitignore")).unwrap();
217        assert_eq!(content.matches(".mars/").count(), 1);
218    }
219}