Skip to main content

mars_agents/cli/
init.rs

1//! `mars init [TARGET] [--link DIR...]` — scaffold a mars project.
2//!
3//! Creates `<project-root>/mars.toml` and `<project-root>/.mars`.
4//! If TARGET is provided, also creates `<project-root>/TARGET` as a managed output dir.
5//! Use `--root` to select an explicit project root.
6//!
7//! Init does NOT walk up — it creates a project at cwd or the `--root` target.
8//! Idempotent: re-running is a no-op for initialization but still processes
9//! `--link` flags.
10
11use std::path::{Path, PathBuf};
12
13use crate::error::{ConfigError, MarsError};
14
15use super::output;
16
17/// Arguments for `mars init`.
18#[derive(Debug, clap::Args)]
19pub struct InitArgs {
20    /// Optional directory name to create for managed output.
21    pub target: Option<String>,
22
23    /// Directories to link after initialization. Repeatable.
24    #[arg(long, value_name = "DIR")]
25    pub link: Vec<String>,
26}
27
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub(super) struct InitializedProject {
30    pub project_root: PathBuf,
31    pub managed_root: Option<PathBuf>,
32    pub already_initialized: bool,
33}
34
35/// Validate that a target is a simple directory name, not a path.
36fn validate_target(target: &str) -> Result<(), MarsError> {
37    if target.contains('/') || target.contains('\\') {
38        return Err(MarsError::Config(ConfigError::Invalid {
39            message: format!(
40                "`{target}` looks like a path — TARGET should be a directory name \
41                 like `.claude` or `.codex`. Use `--root` to specify project root."
42            ),
43        }));
44    }
45    if target == "." || target == ".." || target.is_empty() {
46        return Err(MarsError::Config(ConfigError::Invalid {
47            message: format!(
48                "`{target}` is not a valid target name — use a directory name like `.claude` or `.codex`."
49            ),
50        }));
51    }
52    Ok(())
53}
54
55fn ensure_consumer_config(project_root: &Path) -> Result<bool, MarsError> {
56    let config_path = project_root.join("mars.toml");
57    if config_path.exists() {
58        return Ok(true);
59    }
60
61    crate::fs::atomic_write(&config_path, b"[dependencies]\n")?;
62    Ok(false)
63}
64
65pub(super) fn initialize_project(
66    explicit_root: Option<&Path>,
67    target_override: Option<&str>,
68) -> Result<InitializedProject, MarsError> {
69    let project_root = explicit_root
70        .map(Path::to_path_buf)
71        .unwrap_or_else(|| std::env::current_dir().expect("cannot determine current directory"));
72
73    std::fs::create_dir_all(project_root.join(".mars"))?;
74
75    let already_initialized = ensure_consumer_config(&project_root)?;
76
77    let managed_root = if let Some(target) = explicit_init_target(&project_root, target_override)? {
78        validate_target(&target)?;
79        let managed_root = project_root.join(&target);
80        std::fs::create_dir_all(&managed_root)?;
81        persist_managed_root(&project_root, Some(&target))?;
82        Some(managed_root)
83    } else {
84        persist_managed_root(&project_root, None)?;
85        None
86    };
87
88    Ok(InitializedProject {
89        project_root,
90        managed_root,
91        already_initialized,
92    })
93}
94
95/// Run `mars init`.
96///
97/// Init creates a project at cwd or `--root` target. It does NOT walk up.
98pub fn run(args: &InitArgs, explicit_root: Option<&Path>, json: bool) -> Result<i32, MarsError> {
99    let initialized = initialize_project(explicit_root, args.target.as_deref())?;
100    let project_root = initialized.project_root;
101    let managed_root = initialized.managed_root;
102    let already_initialized = initialized.already_initialized;
103
104    if !json {
105        if already_initialized {
106            output::print_info(&format!("{} already initialized", project_root.display()));
107        } else {
108            output::print_success(&format!(
109                "initialized {} with mars.toml",
110                project_root.display()
111            ));
112        }
113    }
114
115    // 5. Process --link flags
116    if !args.link.is_empty() {
117        let context_managed_root = managed_root
118            .clone()
119            .unwrap_or_else(|| project_root.join(".mars"));
120        let ctx = super::MarsContext::from_roots(project_root.clone(), context_managed_root)?;
121        for link_target in &args.link {
122            let link_args = super::link::LinkArgs {
123                target: link_target.clone(),
124                force: false,
125            };
126            super::link::run(&link_args, &ctx, json)?;
127        }
128    }
129
130    let lossiness = crate::compiler::lossiness_preview::collect_source_lossiness_diagnostics(
131        &project_root,
132        crate::diagnostic::LossinessMode::Surface,
133    )?;
134    if !json && !lossiness.is_empty() {
135        output::print_diagnostics(&lossiness);
136    }
137
138    if json {
139        output::print_json(&serde_json::json!({
140            "ok": true,
141            "project_root": project_root.to_string_lossy(),
142            "managed_root": managed_root.as_ref().map(|path| path.to_string_lossy().to_string()),
143            "already_initialized": already_initialized,
144            "links": args.link,
145        }));
146    }
147
148    Ok(0)
149}
150
151fn explicit_init_target(
152    project_root: &Path,
153    target_override: Option<&str>,
154) -> Result<Option<String>, MarsError> {
155    if let Some(target) = target_override {
156        return Ok(Some(target.to_string()));
157    }
158
159    match crate::config::load(project_root) {
160        Ok(config) => Ok(config.settings.managed_root),
161        Err(MarsError::Config(ConfigError::NotFound { .. })) => Ok(None),
162        Err(e) => Err(e),
163    }
164}
165
166/// Persist managed_root in mars.toml [settings].
167fn persist_managed_root(project_root: &Path, target: Option<&str>) -> Result<(), MarsError> {
168    match crate::config::load(project_root) {
169        Ok(mut config) => {
170            config.settings.managed_root = target.map(str::to_string);
171            crate::config::save(project_root, &config)?;
172        }
173        Err(MarsError::Config(ConfigError::NotFound { .. })) => {
174            // Config will be created by ensure_consumer_config — skip
175        }
176        Err(e) => return Err(e),
177    }
178    Ok(())
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use tempfile::TempDir;
185
186    #[test]
187    fn init_on_empty_project_runs_lossiness_pass_without_error() {
188        let dir = TempDir::new().unwrap();
189        let args = super::InitArgs {
190            target: None,
191            link: Vec::new(),
192        };
193        let code = super::run(&args, Some(dir.path()), false).unwrap();
194        assert_eq!(code, 0);
195    }
196
197    #[test]
198    fn validate_target_accepts_simple_names() {
199        assert!(validate_target(".agents").is_ok());
200        assert!(validate_target(".claude").is_ok());
201        assert!(validate_target("my-agents").is_ok());
202    }
203
204    #[test]
205    fn validate_target_rejects_paths() {
206        assert!(validate_target("./foo").is_err());
207        assert!(validate_target("foo/bar").is_err());
208        assert!(validate_target("/absolute/path").is_err());
209    }
210
211    #[test]
212    fn validate_target_rejects_dots() {
213        assert!(validate_target(".").is_err());
214        assert!(validate_target("..").is_err());
215    }
216
217    #[test]
218    fn validate_target_rejects_empty() {
219        assert!(validate_target("").is_err());
220    }
221
222    #[test]
223    fn ensure_consumer_config_creates_root_mars_toml() {
224        let dir = TempDir::new().unwrap();
225
226        let already = ensure_consumer_config(dir.path()).unwrap();
227        assert!(!already);
228
229        let content = std::fs::read_to_string(dir.path().join("mars.toml")).unwrap();
230        assert!(content.contains("[dependencies]"));
231    }
232
233    #[test]
234    fn ensure_consumer_config_accepts_existing_mars_toml() {
235        let dir = TempDir::new().unwrap();
236        std::fs::write(
237            dir.path().join("mars.toml"),
238            "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
239        )
240        .unwrap();
241
242        let already = ensure_consumer_config(dir.path()).unwrap();
243        assert!(already);
244    }
245
246    #[test]
247    fn initialize_project_without_target_creates_mars_only() {
248        let dir = TempDir::new().unwrap();
249
250        let initialized = initialize_project(Some(dir.path()), None).unwrap();
251
252        assert!(dir.path().join(".mars").exists());
253        assert!(!dir.path().join(".agents").exists());
254        assert!(initialized.managed_root.is_none());
255
256        let config = crate::config::load(dir.path()).unwrap();
257        assert!(config.settings.managed_root.is_none());
258    }
259
260    #[test]
261    fn initialize_project_with_explicit_target_persists_managed_root() {
262        let dir = TempDir::new().unwrap();
263
264        let initialized = initialize_project(Some(dir.path()), Some(".claude")).unwrap();
265
266        assert!(dir.path().join(".mars").exists());
267        assert!(dir.path().join(".claude").exists());
268        assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
269
270        let config = crate::config::load(dir.path()).unwrap();
271        assert_eq!(config.settings.managed_root.as_deref(), Some(".claude"));
272    }
273
274    #[test]
275    fn initialize_project_preserves_existing_managed_root_when_no_target_given() {
276        let dir = TempDir::new().unwrap();
277        std::fs::write(
278            dir.path().join("mars.toml"),
279            "[settings]\nmanaged_root = \".claude\"\n",
280        )
281        .unwrap();
282
283        let initialized = initialize_project(Some(dir.path()), None).unwrap();
284
285        assert!(dir.path().join(".claude").exists());
286        assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
287    }
288
289    #[test]
290    fn initialize_project_with_explicit_agents_persists_deprecated_target() {
291        let dir = TempDir::new().unwrap();
292
293        let initialized = initialize_project(Some(dir.path()), Some(".agents")).unwrap();
294
295        assert!(dir.path().join(".agents").exists());
296        assert_eq!(initialized.managed_root, Some(dir.path().join(".agents")));
297
298        let config = crate::config::load(dir.path()).unwrap();
299        assert_eq!(config.settings.managed_root.as_deref(), Some(".agents"));
300    }
301}