1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProjectConfig {
18 pub name: String,
19}
20
21#[derive(Debug, Clone)]
23pub struct ProjectContext {
24 pub name: String,
25 pub path: PathBuf,
26}
27
28const MAX_NAME_LEN: usize = 64;
34
35pub fn validate_project_name(name: &str) -> crate::Result<()> {
41 if name.is_empty() {
42 return Err(crate::Error::Io("project name must not be empty".into()));
43 }
44 if name.len() > MAX_NAME_LEN {
45 return Err(crate::Error::Io(format!(
46 "project name exceeds {MAX_NAME_LEN} characters"
47 )));
48 }
49
50 let bytes = name.as_bytes();
51
52 if !bytes[0].is_ascii_lowercase() && !bytes[0].is_ascii_digit() {
54 return Err(crate::Error::Io(
55 "project name must start with a lowercase letter or digit".into(),
56 ));
57 }
58
59 for &b in &bytes[1..] {
61 if !b.is_ascii_lowercase() && !b.is_ascii_digit() && b != b'_' && b != b'-' {
62 return Err(crate::Error::Io(format!(
63 "project name contains invalid character '{}'",
64 b as char,
65 )));
66 }
67 }
68
69 Ok(())
70}
71
72pub fn slugify(name: &str) -> String {
81 let mut slug = String::with_capacity(name.len());
82
83 for ch in name.chars() {
84 let lower = ch.to_ascii_lowercase();
85 if lower.is_ascii_lowercase() || lower.is_ascii_digit() || lower == '_' || lower == '-' {
86 slug.push(lower);
87 } else {
88 slug.push('-');
89 }
90 }
91
92 let mut collapsed = String::with_capacity(slug.len());
94 let mut prev_dash = false;
95 for ch in slug.chars() {
96 if ch == '-' {
97 if !prev_dash {
98 collapsed.push(ch);
99 }
100 prev_dash = true;
101 } else {
102 prev_dash = false;
103 collapsed.push(ch);
104 }
105 }
106
107 let trimmed =
109 collapsed.trim_start_matches(|c: char| !c.is_ascii_lowercase() && !c.is_ascii_digit());
110
111 let mut result = trimmed.to_string();
112
113 if result.len() > MAX_NAME_LEN {
115 result.truncate(MAX_NAME_LEN);
116 result = result.trim_end_matches('-').to_string();
118 }
119
120 if result.is_empty() {
121 return "project".to_string();
122 }
123
124 result
125}
126
127pub fn check_git_repo(path: &Path) -> crate::Result<()> {
149 let output = std::process::Command::new("git")
150 .args(["rev-parse", "--show-toplevel"])
151 .current_dir(path)
152 .output();
153
154 let output = match output {
155 Ok(o) => o,
156 Err(e) => {
157 return Err(crate::Error::Io(format!(
158 "failed to run `git rev-parse` in {}: {}. \
159 tau projects require a git repository at the project root; \
160 install git and run `git init` here, then retry.",
161 path.display(),
162 e,
163 )));
164 }
165 };
166
167 if !output.status.success() {
168 let stderr = String::from_utf8_lossy(&output.stderr);
169 return Err(crate::Error::Io(format!(
170 "tau projects require a git repository at the project root \
171 ({}). Run `git init` in this directory first, then retry \
172 `tau project init` (git said: {}).",
173 path.display(),
174 stderr.trim(),
175 )));
176 }
177
178 Ok(())
179}
180
181pub fn discover_project(start: &Path) -> Option<(String, PathBuf)> {
185 let mut dir = if start.is_absolute() {
186 start.to_path_buf()
187 } else {
188 std::env::current_dir().ok()?.join(start)
189 };
190
191 loop {
192 let config_path = dir.join(".tau").join("project.toml");
193 if config_path.is_file() {
194 let contents = match std::fs::read_to_string(&config_path) {
195 Ok(c) => c,
196 Err(e) => {
197 tracing::warn!(
198 path = %config_path.display(),
199 error = %e,
200 "discover_project: failed to read project.toml",
201 );
202 return None;
203 }
204 };
205 let config: ProjectConfig = match toml::from_str(&contents) {
206 Ok(c) => c,
207 Err(e) => {
208 tracing::warn!(
209 path = %config_path.display(),
210 error = %e,
211 "discover_project: malformed project.toml",
212 );
213 return None;
214 }
215 };
216 let canonical = match dir.canonicalize() {
217 Ok(p) => p,
218 Err(e) => {
219 tracing::warn!(
220 dir = %dir.display(),
221 error = %e,
222 "discover_project: failed to canonicalize project root",
223 );
224 return None;
225 }
226 };
227 return Some((config.name, canonical));
228 }
229 if !dir.pop() {
230 return None;
231 }
232 }
233}
234
235pub fn init_project(path: &Path, name: &str) -> crate::Result<PathBuf> {
248 validate_project_name(name)?;
249
250 check_git_repo(path)?;
256
257 let tau_dir = path.join(".tau");
258 let config_path = tau_dir.join("project.toml");
259
260 if config_path.exists() {
261 return Err(crate::Error::Io(format!(
262 "project already initialized: {} exists",
263 config_path.display(),
264 )));
265 }
266
267 std::fs::create_dir_all(&tau_dir).map_err(|e| crate::Error::Io(e.to_string()))?;
269
270 let config = ProjectConfig {
272 name: name.to_string(),
273 };
274 let toml_content =
275 toml::to_string_pretty(&config).map_err(|e| crate::Error::Io(e.to_string()))?;
276 std::fs::write(&config_path, toml_content).map_err(|e| crate::Error::Io(e.to_string()))?;
277
278 let gitignore_path = tau_dir.join(".gitignore");
280 let worktrees_line = "/worktrees/";
281 if gitignore_path.exists() {
282 let existing = std::fs::read_to_string(&gitignore_path)
283 .map_err(|e| crate::Error::Io(e.to_string()))?;
284 if !existing.lines().any(|line| line.trim() == worktrees_line) {
285 let mut content = existing;
286 if !content.ends_with('\n') && !content.is_empty() {
287 content.push('\n');
288 }
289 content.push_str(worktrees_line);
290 content.push('\n');
291 std::fs::write(&gitignore_path, content)
292 .map_err(|e| crate::Error::Io(e.to_string()))?;
293 }
294 } else {
295 std::fs::write(&gitignore_path, format!("{worktrees_line}\n"))
296 .map_err(|e| crate::Error::Io(e.to_string()))?;
297 }
298
299 let operator_dir = crate::paths::config_dir().join("projects").join(name);
301 std::fs::create_dir_all(&operator_dir).map_err(|e| crate::Error::Io(e.to_string()))?;
302
303 path.canonicalize()
305 .map_err(|e| crate::Error::Io(e.to_string()))
306}
307
308#[cfg(test)]
313mod tests {
314 use super::*;
315
316 #[test]
319 fn valid_names() {
320 assert!(validate_project_name("a").is_ok());
321 assert!(validate_project_name("abc").is_ok());
322 assert!(validate_project_name("my-project").is_ok());
323 assert!(validate_project_name("my_project").is_ok());
324 assert!(validate_project_name("0cool").is_ok());
325 assert!(validate_project_name("a1-b2_c3").is_ok());
326 }
327
328 #[test]
329 fn invalid_empty() {
330 assert!(validate_project_name("").is_err());
331 }
332
333 #[test]
334 fn invalid_too_long() {
335 let long = "a".repeat(65);
336 assert!(validate_project_name(&long).is_err());
337 let exact = "a".repeat(64);
339 assert!(validate_project_name(&exact).is_ok());
340 }
341
342 #[test]
343 fn invalid_start_char() {
344 assert!(validate_project_name("-foo").is_err());
345 assert!(validate_project_name("_foo").is_err());
346 assert!(validate_project_name(".foo").is_err());
347 }
348
349 #[test]
350 fn invalid_uppercase() {
351 assert!(validate_project_name("Foo").is_err());
352 assert!(validate_project_name("fOo").is_err());
353 }
354
355 #[test]
356 fn invalid_special_chars() {
357 assert!(validate_project_name("foo bar").is_err());
358 assert!(validate_project_name("foo.bar").is_err());
359 assert!(validate_project_name("foo/bar").is_err());
360 }
361
362 #[test]
365 fn slugify_basic() {
366 assert_eq!(slugify("MyProject"), "myproject");
367 }
368
369 #[test]
370 fn slugify_spaces_and_dots() {
371 assert_eq!(slugify("My Cool Project"), "my-cool-project");
372 assert_eq!(slugify("foo.bar.baz"), "foo-bar-baz");
373 }
374
375 #[test]
376 fn slugify_leading_invalid() {
377 assert_eq!(slugify("--foo"), "foo");
378 assert_eq!(slugify("__bar"), "bar");
379 assert_eq!(slugify("...baz"), "baz");
380 }
381
382 #[test]
383 fn slugify_collapse_dashes() {
384 assert_eq!(slugify("a---b"), "a-b");
385 }
386
387 #[test]
388 fn slugify_empty_fallback() {
389 assert_eq!(slugify(""), "project");
390 assert_eq!(slugify("..."), "project");
391 }
392
393 #[test]
394 fn slugify_truncate() {
395 let long = "a".repeat(100);
396 let result = slugify(&long);
397 assert!(result.len() <= MAX_NAME_LEN);
398 assert!(validate_project_name(&result).is_ok());
399 }
400
401 #[test]
402 fn slugify_result_is_valid() {
403 let cases = ["My Project", "foo/bar", "__init__", "CamelCase123"];
404 for input in &cases {
405 let s = slugify(input);
406 assert!(
407 validate_project_name(&s).is_ok(),
408 "slugify({input:?}) = {s:?} failed validation",
409 );
410 }
411 }
412
413 #[test]
419 fn discover_finds_project_at_root() {
420 let tmp = tempfile::tempdir().expect("create tempdir");
421 let root = tmp.path();
422
423 let tau_dir = root.join(".tau");
425 std::fs::create_dir_all(&tau_dir).unwrap();
426 std::fs::write(tau_dir.join("project.toml"), "name = \"test-proj\"\n").unwrap();
427
428 let (name, found_path) = discover_project(root).expect("should discover");
429 assert_eq!(name, "test-proj");
430 assert_eq!(found_path, root.canonicalize().unwrap());
431 }
432
433 #[test]
434 fn discover_walks_up() {
435 let tmp = tempfile::tempdir().expect("create tempdir");
436 let root = tmp.path();
437
438 let tau_dir = root.join(".tau");
439 std::fs::create_dir_all(&tau_dir).unwrap();
440 std::fs::write(tau_dir.join("project.toml"), "name = \"walk-up\"\n").unwrap();
441
442 let nested = root.join("src").join("deep");
444 std::fs::create_dir_all(&nested).unwrap();
445
446 let (name, found_path) = discover_project(&nested).expect("should discover");
447 assert_eq!(name, "walk-up");
448 assert_eq!(found_path, root.canonicalize().unwrap());
449 }
450
451 #[test]
452 fn discover_returns_none_when_missing() {
453 let tmp = tempfile::tempdir().expect("create tempdir");
454 assert!(discover_project(tmp.path()).is_none());
455 }
456
457 #[test]
458 fn discover_with_trailing_slash() {
459 let tmp = tempfile::tempdir().expect("create tempdir");
460 let root = tmp.path();
461 let tau_dir = root.join(".tau");
462 std::fs::create_dir_all(&tau_dir).unwrap();
463 std::fs::write(tau_dir.join("project.toml"), "name = \"trailing\"\n").unwrap();
464
465 let mut with_slash = root.to_string_lossy().into_owned();
467 with_slash.push('/');
468 let p = std::path::Path::new(&with_slash);
469
470 let (name, found) = discover_project(p).expect("should discover");
471 assert_eq!(name, "trailing");
472 assert_eq!(found, root.canonicalize().unwrap());
473 }
474
475 #[test]
476 fn discover_with_dot_dot_in_path() {
477 let tmp = tempfile::tempdir().expect("create tempdir");
478 let root = tmp.path();
479 let tau_dir = root.join(".tau");
480 std::fs::create_dir_all(&tau_dir).unwrap();
481 std::fs::write(tau_dir.join("project.toml"), "name = \"dotdot\"\n").unwrap();
482
483 let sub = root.join("sub");
485 std::fs::create_dir_all(&sub).unwrap();
486 let weird = sub.join("..");
487
488 let (name, found) = discover_project(&weird).expect("should discover via .. path");
489 assert_eq!(name, "dotdot");
490 assert_eq!(found, root.canonicalize().unwrap());
492 }
493
494 #[test]
495 fn discover_nonexistent_path_returns_none() {
496 let tmp = tempfile::tempdir().expect("create tempdir");
497 let bogus = tmp.path().join("does").join("not").join("exist");
498 assert!(discover_project(&bogus).is_none());
500 }
501
502 #[test]
503 fn discover_malformed_toml_returns_none() {
504 let tmp = tempfile::tempdir().expect("create tempdir");
505 let root = tmp.path();
506 let tau_dir = root.join(".tau");
507 std::fs::create_dir_all(&tau_dir).unwrap();
508 std::fs::write(tau_dir.join("project.toml"), "name = \n").unwrap();
510
511 assert!(discover_project(root).is_none());
512 }
513
514 #[cfg(unix)]
515 #[test]
516 fn discover_via_symlink_to_project_root() {
517 let tmp = tempfile::tempdir().expect("create tempdir");
518 let root = tmp.path().join("real");
519 std::fs::create_dir_all(&root).unwrap();
520 let tau_dir = root.join(".tau");
521 std::fs::create_dir_all(&tau_dir).unwrap();
522 std::fs::write(tau_dir.join("project.toml"), "name = \"sym\"\n").unwrap();
523
524 let link = tmp.path().join("link");
525 std::os::unix::fs::symlink(&root, &link).unwrap();
526
527 let (name, found) = discover_project(&link).expect("should discover via symlink");
528 assert_eq!(name, "sym");
529 assert_eq!(found, root.canonicalize().unwrap());
531 }
532
533 fn git_init(path: &Path) {
539 let status = std::process::Command::new("git")
540 .args(["init", "-q", "-b", "main"])
541 .current_dir(path)
542 .status()
543 .expect("spawn git init");
544 assert!(status.success(), "git init failed in {}", path.display());
545 }
546
547 #[test]
548 fn init_creates_files() {
549 let _lock = crate::TEST_ENV_MUTEX
550 .lock()
551 .unwrap_or_else(|p| p.into_inner());
552
553 let tmp = tempfile::tempdir().expect("create tempdir");
554 let root = tmp.path();
555 git_init(root);
556
557 let config_tmp = tempfile::tempdir().expect("create config tempdir");
559 unsafe { std::env::set_var("XDG_CONFIG_HOME", config_tmp.path()) };
560
561 let operator_dir = crate::paths::config_dir()
562 .join("projects")
563 .join("test-init");
564 let result = init_project(root, "test-init");
565
566 let canonical = result.expect("init_project should succeed");
567 assert_eq!(canonical, root.canonicalize().unwrap());
568
569 let toml_content = std::fs::read_to_string(root.join(".tau").join("project.toml")).unwrap();
571 let config: ProjectConfig = toml::from_str(&toml_content).unwrap();
572 assert_eq!(config.name, "test-init");
573
574 let gitignore = std::fs::read_to_string(root.join(".tau").join(".gitignore")).unwrap();
576 assert!(gitignore.contains("/worktrees/"));
577
578 assert!(operator_dir.is_dir());
580
581 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
582 }
583
584 #[test]
585 fn init_rejects_non_git_dir() {
586 let _lock = crate::TEST_ENV_MUTEX
587 .lock()
588 .unwrap_or_else(|p| p.into_inner());
589
590 let tmp = tempfile::tempdir().expect("create tempdir");
591 let root = tmp.path();
592 let config_tmp = tempfile::tempdir().expect("create config tempdir");
595 unsafe { std::env::set_var("XDG_CONFIG_HOME", config_tmp.path()) };
596
597 let err = init_project(root, "non-git")
598 .expect_err("init_project should refuse a non-git directory");
599 let msg = format!("{}", err);
600 assert!(
601 msg.to_lowercase().contains("git"),
602 "error should mention git, got: {msg}"
603 );
604 assert!(
605 msg.contains("git init"),
606 "error should hint at `git init`, got: {msg}"
607 );
608
609 assert!(
611 !root.join(".tau").exists(),
612 "failed init must not create .tau/",
613 );
614
615 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
616 }
617
618 #[test]
619 fn init_rejects_bare_repo() {
620 let _lock = crate::TEST_ENV_MUTEX
621 .lock()
622 .unwrap_or_else(|p| p.into_inner());
623
624 let tmp = tempfile::tempdir().expect("create tempdir");
625 let root = tmp.path();
626 let status = std::process::Command::new("git")
627 .args(["init", "-q", "--bare"])
628 .current_dir(root)
629 .status()
630 .expect("spawn git init --bare");
631 assert!(status.success(), "git init --bare failed");
632
633 let config_tmp = tempfile::tempdir().expect("create config tempdir");
634 unsafe { std::env::set_var("XDG_CONFIG_HOME", config_tmp.path()) };
635
636 let err = init_project(root, "bare-repo").expect_err("bare repos should be rejected");
639 assert!(
640 format!("{}", err).to_lowercase().contains("git"),
641 "error should mention git"
642 );
643
644 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
645 }
646
647 #[test]
648 fn init_rejects_invalid_name() {
649 let tmp = tempfile::tempdir().expect("create tempdir");
650 assert!(init_project(tmp.path(), "Bad Name!").is_err());
653 }
654
655 #[test]
656 fn init_rejects_duplicate() {
657 let _lock = crate::TEST_ENV_MUTEX
658 .lock()
659 .unwrap_or_else(|p| p.into_inner());
660
661 let tmp = tempfile::tempdir().expect("create tempdir");
662 let root = tmp.path();
663 git_init(root);
664
665 let config_tmp = tempfile::tempdir().expect("create config tempdir");
666 unsafe { std::env::set_var("XDG_CONFIG_HOME", config_tmp.path()) };
667
668 init_project(root, "dup-test").expect("first init should succeed");
669
670 let err = init_project(root, "dup-test");
671 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
672
673 assert!(err.is_err());
674 }
675
676 #[test]
677 fn init_gitignore_no_duplicate_line() {
678 let _lock = crate::TEST_ENV_MUTEX
679 .lock()
680 .unwrap_or_else(|p| p.into_inner());
681
682 let tmp = tempfile::tempdir().expect("create tempdir");
683 let root = tmp.path();
684 git_init(root);
685
686 let tau_dir = root.join(".tau");
688 std::fs::create_dir_all(&tau_dir).unwrap();
689 std::fs::write(tau_dir.join(".gitignore"), "/worktrees/\n").unwrap();
690
691 let config_tmp = tempfile::tempdir().expect("create config tempdir");
692 unsafe { std::env::set_var("XDG_CONFIG_HOME", config_tmp.path()) };
693
694 init_project(root, "gi-test").expect("init should succeed");
695
696 unsafe { std::env::remove_var("XDG_CONFIG_HOME") };
697
698 let gitignore = std::fs::read_to_string(tau_dir.join(".gitignore")).unwrap();
699 let count = gitignore
700 .lines()
701 .filter(|l| l.trim() == "/worktrees/")
702 .count();
703 assert_eq!(count, 1, "should not duplicate /worktrees/ line");
704 }
705}