Skip to main content

tau_agent_base/
project.rs

1//! Project configuration, discovery, and initialization.
2//!
3//! A tau project is identified by a `.tau/project.toml` file at its root.
4//! This module provides helpers to validate project names, discover existing
5//! projects by walking up the directory tree, and initialize new ones.
6
7use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11// ---------------------------------------------------------------------------
12// Types
13// ---------------------------------------------------------------------------
14
15/// Persisted project configuration stored in `.tau/project.toml`.
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct ProjectConfig {
18    pub name: String,
19}
20
21/// In-memory handle to a discovered or initialized project.
22#[derive(Debug, Clone)]
23pub struct ProjectContext {
24    pub name: String,
25    pub path: PathBuf,
26}
27
28// ---------------------------------------------------------------------------
29// Validation & helpers
30// ---------------------------------------------------------------------------
31
32/// Maximum length for a project name.
33const MAX_NAME_LEN: usize = 64;
34
35/// Validate that `name` is a legal project name.
36///
37/// Rules:
38/// - Matches `^[a-z0-9][a-z0-9_-]*$`
39/// - At most 64 characters
40pub 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    // First character: [a-z0-9]
53    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    // Remaining characters: [a-z0-9_-]
60    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
72/// Convert a directory name (or arbitrary string) into a valid project name.
73///
74/// - Converts to lowercase
75/// - Replaces any character outside `[a-z0-9_-]` with `-`
76/// - Collapses consecutive `-` into one
77/// - Strips leading non-`[a-z0-9]` characters
78/// - Truncates to [`MAX_NAME_LEN`]
79/// - Falls back to `"project"` if the result would be empty
80pub 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    // Collapse consecutive dashes.
93    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    // Strip leading characters that are not [a-z0-9].
108    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    // Truncate to max length.
114    if result.len() > MAX_NAME_LEN {
115        result.truncate(MAX_NAME_LEN);
116        // Don't leave a trailing dash after truncation.
117        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
127// ---------------------------------------------------------------------------
128// Discovery
129// ---------------------------------------------------------------------------
130
131/// Verify that `path` is inside a git working tree by running
132/// `git -C <path> rev-parse --show-toplevel`.
133///
134/// Returns `Ok(())` if `path` is part of a (non-bare) git repository, and
135/// `Err(Error::Io(...))` with a user-facing remediation hint otherwise. The
136/// caller is expected to surface the error verbatim — the message is the
137/// only signal users see in CLI / tool-call output.
138///
139/// Used at project-init time to refuse projects that cannot host task
140/// worktrees. Tasks that don't need a worktree (`no_merge` tasks, see
141/// task #942) still run fine in a non-git directory once the project is
142/// registered, but registering a project requires a real working tree —
143/// the validation runs at init only, not at task-create.
144//
145// TODO(#942 follow-up): once `tau project init --no-git` lands, gate this
146// check on a `git_required` flag in `ProjectConfig` (default true) and
147// only refuse when the flag is set.
148pub 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
181/// Walk up from `start` looking for `.tau/project.toml`.
182///
183/// Returns `(project_name, canonicalized_project_root)` if found.
184pub 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
235// ---------------------------------------------------------------------------
236// Initialization
237// ---------------------------------------------------------------------------
238
239/// Initialize a new tau project at `path` with the given `name`.
240///
241/// Creates:
242/// - `<path>/.tau/project.toml` with the project config
243/// - `<path>/.tau/.gitignore` containing `/worktrees/`
244/// - Operator config dir at `<config_dir>/projects/<name>/`
245///
246/// Returns the canonicalized project root path.
247pub fn init_project(path: &Path, name: &str) -> crate::Result<PathBuf> {
248    validate_project_name(name)?;
249
250    // Refuse to register a project unless its root is inside a real git
251    // working tree (#949). Without a worktree we cannot create the
252    // per-task branches/worktrees the scheduler relies on, and the
253    // failure mode (silent retry storm in the auto-scheduler) is much
254    // worse than refusing up front.
255    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    // Create .tau/ directory.
268    std::fs::create_dir_all(&tau_dir).map_err(|e| crate::Error::Io(e.to_string()))?;
269
270    // Write project.toml.
271    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    // Write or update .gitignore.
279    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    // Create operator config directory.
300    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    // Return canonicalized project root.
304    path.canonicalize()
305        .map_err(|e| crate::Error::Io(e.to_string()))
306}
307
308// ---------------------------------------------------------------------------
309// Tests
310// ---------------------------------------------------------------------------
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315
316    // -- validate_project_name ------------------------------------------------
317
318    #[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        // Exactly 64 is fine.
338        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    // -- slugify --------------------------------------------------------------
363
364    #[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    // -- discover_project -----------------------------------------------------
414
415    // Tests that modify environment variables must be serialized to avoid
416    // races with the default parallel test runner.
417
418    #[test]
419    fn discover_finds_project_at_root() {
420        let tmp = tempfile::tempdir().expect("create tempdir");
421        let root = tmp.path();
422
423        // Set up .tau/project.toml
424        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        // Create a nested directory.
443        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        // Append a trailing slash to the path.
466        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        // Build a path of the form `<root>/sub/..` that resolves to root.
484        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        // Canonicalisation collapses `..` so the discovered root matches the real one.
491        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        // Must not panic and must return None.
499        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        // Not valid TOML (missing quotes / value).
509        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        // Canonicalised path resolves through the symlink to the real dir.
530        assert_eq!(found, root.canonicalize().unwrap());
531    }
532
533    // -- init_project ---------------------------------------------------------
534
535    /// Run `git init -q -b main` in `path` so `init_project` (which now
536    /// requires a real working tree) accepts it. Best-effort: tests that
537    /// don't care about git state can call this once and forget.
538    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        // Override config dir so we don't touch the real home.
558        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        // .tau/project.toml exists and is valid.
570        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        // .tau/.gitignore contains /worktrees/.
575        let gitignore = std::fs::read_to_string(root.join(".tau").join(".gitignore")).unwrap();
576        assert!(gitignore.contains("/worktrees/"));
577
578        // Operator config dir was created.
579        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        // No `git init` here — the directory is not a git working tree.
593
594        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        // The init_project call must not leave any artifacts behind.
610        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        // Bare repos have no working tree — `rev-parse --show-toplevel`
637        // exits non-zero, so init_project must refuse them.
638        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        // Note: invalid name should be rejected before the git check
651        // even fires, so an init-less tempdir is fine here.
652        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        // Pre-create .tau/.gitignore with the line already present.
687        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}