1use std::path::{Path, PathBuf};
12
13use crate::error::{ConfigError, MarsError};
14
15use super::output;
16
17#[derive(Debug, clap::Args)]
19pub struct InitArgs {
20 pub target: Option<String>,
22
23 #[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
35fn 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
95pub 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 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
166fn 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 }
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}