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 };
125 super::link::run(&link_args, &ctx, json)?;
126 }
127 }
128
129 if json {
130 output::print_json(&serde_json::json!({
131 "ok": true,
132 "project_root": project_root.to_string_lossy(),
133 "managed_root": managed_root.as_ref().map(|path| path.to_string_lossy().to_string()),
134 "already_initialized": already_initialized,
135 "links": args.link,
136 }));
137 }
138
139 Ok(0)
140}
141
142fn explicit_init_target(
143 project_root: &Path,
144 target_override: Option<&str>,
145) -> Result<Option<String>, MarsError> {
146 if let Some(target) = target_override {
147 return Ok(Some(target.to_string()));
148 }
149
150 match crate::config::load(project_root) {
151 Ok(config) => Ok(config.settings.managed_root),
152 Err(MarsError::Config(ConfigError::NotFound { .. })) => Ok(None),
153 Err(e) => Err(e),
154 }
155}
156
157fn persist_managed_root(project_root: &Path, target: Option<&str>) -> Result<(), MarsError> {
159 match crate::config::load(project_root) {
160 Ok(mut config) => {
161 config.settings.managed_root = target.map(str::to_string);
162 crate::config::save(project_root, &config)?;
163 }
164 Err(MarsError::Config(ConfigError::NotFound { .. })) => {
165 }
167 Err(e) => return Err(e),
168 }
169 Ok(())
170}
171
172#[cfg(test)]
173mod tests {
174 use super::*;
175 use tempfile::TempDir;
176
177 #[test]
178 fn validate_target_accepts_simple_names() {
179 assert!(validate_target(".agents").is_ok());
180 assert!(validate_target(".claude").is_ok());
181 assert!(validate_target("my-agents").is_ok());
182 }
183
184 #[test]
185 fn validate_target_rejects_paths() {
186 assert!(validate_target("./foo").is_err());
187 assert!(validate_target("foo/bar").is_err());
188 assert!(validate_target("/absolute/path").is_err());
189 }
190
191 #[test]
192 fn validate_target_rejects_dots() {
193 assert!(validate_target(".").is_err());
194 assert!(validate_target("..").is_err());
195 }
196
197 #[test]
198 fn validate_target_rejects_empty() {
199 assert!(validate_target("").is_err());
200 }
201
202 #[test]
203 fn ensure_consumer_config_creates_root_mars_toml() {
204 let dir = TempDir::new().unwrap();
205
206 let already = ensure_consumer_config(dir.path()).unwrap();
207 assert!(!already);
208
209 let content = std::fs::read_to_string(dir.path().join("mars.toml")).unwrap();
210 assert!(content.contains("[dependencies]"));
211 }
212
213 #[test]
214 fn ensure_consumer_config_accepts_existing_mars_toml() {
215 let dir = TempDir::new().unwrap();
216 std::fs::write(
217 dir.path().join("mars.toml"),
218 "[package]\nname = \"pkg\"\nversion = \"0.1.0\"\n",
219 )
220 .unwrap();
221
222 let already = ensure_consumer_config(dir.path()).unwrap();
223 assert!(already);
224 }
225
226 #[test]
227 fn initialize_project_without_target_creates_mars_only() {
228 let dir = TempDir::new().unwrap();
229
230 let initialized = initialize_project(Some(dir.path()), None).unwrap();
231
232 assert!(dir.path().join(".mars").exists());
233 assert!(!dir.path().join(".agents").exists());
234 assert!(initialized.managed_root.is_none());
235
236 let config = crate::config::load(dir.path()).unwrap();
237 assert!(config.settings.managed_root.is_none());
238 }
239
240 #[test]
241 fn initialize_project_with_explicit_target_persists_managed_root() {
242 let dir = TempDir::new().unwrap();
243
244 let initialized = initialize_project(Some(dir.path()), Some(".claude")).unwrap();
245
246 assert!(dir.path().join(".mars").exists());
247 assert!(dir.path().join(".claude").exists());
248 assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
249
250 let config = crate::config::load(dir.path()).unwrap();
251 assert_eq!(config.settings.managed_root.as_deref(), Some(".claude"));
252 }
253
254 #[test]
255 fn initialize_project_preserves_existing_managed_root_when_no_target_given() {
256 let dir = TempDir::new().unwrap();
257 std::fs::write(
258 dir.path().join("mars.toml"),
259 "[settings]\nmanaged_root = \".claude\"\n",
260 )
261 .unwrap();
262
263 let initialized = initialize_project(Some(dir.path()), None).unwrap();
264
265 assert!(dir.path().join(".claude").exists());
266 assert_eq!(initialized.managed_root, Some(dir.path().join(".claude")));
267 }
268
269 #[test]
270 fn initialize_project_with_explicit_agents_persists_deprecated_target() {
271 let dir = TempDir::new().unwrap();
272
273 let initialized = initialize_project(Some(dir.path()), Some(".agents")).unwrap();
274
275 assert!(dir.path().join(".agents").exists());
276 assert_eq!(initialized.managed_root, Some(dir.path().join(".agents")));
277
278 let config = crate::config::load(dir.path()).unwrap();
279 assert_eq!(config.settings.managed_root.as_deref(), Some(".agents"));
280 }
281}