Skip to main content

aristo_cli/
workspace.rs

1//! Workspace discovery: find `aristo.toml` by walking up from a starting
2//! directory.
3//!
4//! Aristo's notion of "workspace" matches Cargo's: the directory containing
5//! `aristo.toml` is the root, and `.aristo/{index.toml, specs/, doc/}`
6//! lives next to it. Walking upward from the user's cwd lets `aristo`
7//! commands work from any subdirectory of a project, like `cargo` does.
8
9use std::path::{Path, PathBuf};
10
11#[derive(Debug, PartialEq, Eq)]
12pub enum WorkspaceError {
13    NotFound { searched_from: PathBuf },
14}
15
16impl std::fmt::Display for WorkspaceError {
17    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
18        match self {
19            WorkspaceError::NotFound { searched_from } => {
20                write!(
21                    f,
22                    "no aristo.toml found at or above {}",
23                    searched_from.display()
24                )
25            }
26        }
27    }
28}
29
30impl std::error::Error for WorkspaceError {}
31
32/// A located Aristo workspace.
33#[derive(Debug, Clone)]
34pub struct Workspace {
35    pub root: PathBuf,
36}
37
38impl Workspace {
39    /// Find the workspace by walking upward from `start` (or cwd if None)
40    /// looking for an `aristo.toml`. The first ancestor directory that
41    /// contains one becomes the workspace root.
42    pub fn find(start: Option<&Path>) -> Result<Self, WorkspaceError> {
43        let start_buf = match start {
44            Some(p) => p.to_path_buf(),
45            None => std::env::current_dir().expect("current_dir readable for workspace discovery"),
46        };
47        let mut cur: &Path = &start_buf;
48        loop {
49            if cur.join("aristo.toml").is_file() {
50                return Ok(Workspace {
51                    root: cur.to_path_buf(),
52                });
53            }
54            cur = match cur.parent() {
55                Some(p) => p,
56                None => {
57                    return Err(WorkspaceError::NotFound {
58                        searched_from: start_buf,
59                    });
60                }
61            };
62        }
63    }
64
65    /// Path to the `.aristo/` state directory.
66    pub fn aristo_dir(&self) -> PathBuf {
67        self.root.join(".aristo")
68    }
69
70    /// Path to `.aristo/index.toml`.
71    pub fn index_path(&self) -> PathBuf {
72        self.aristo_dir().join("index.toml")
73    }
74
75    /// Path to `.aristo/canon-matches.toml` — the per-repo cache of
76    /// server-side canon match responses (committed by default).
77    /// See `aristo_core::canon::cache` for the schema and
78    /// `../aretta-sdk/docs/mockups/13-canon-and-matching/README.md`
79    /// §L5 for the design.
80    pub fn canon_matches_path(&self) -> PathBuf {
81        self.aristo_dir().join("canon-matches.toml")
82    }
83
84    /// Path to `.aristo/specs/`.
85    pub fn specs_dir(&self) -> PathBuf {
86        self.aristo_dir().join("specs")
87    }
88
89    /// Path to `.aristo/doc/`.
90    pub fn doc_dir(&self) -> PathBuf {
91        self.aristo_dir().join("doc")
92    }
93
94    /// Path to `.aristo/sessions/`. Holds the review-session substrate's
95    /// local-only state — see `docs/decisions/review-sessions.md` §D5.
96    /// Everything under this directory is gitignored.
97    pub fn sessions_dir(&self) -> PathBuf {
98        self.aristo_dir().join("sessions")
99    }
100
101    /// Pointer file holding the active session id (if any). Existence
102    /// implies an active session; missing means none.
103    pub fn sessions_active_pointer(&self) -> PathBuf {
104        self.sessions_dir().join(".active")
105    }
106
107    /// Per-session TOML state for in-flight sessions.
108    pub fn sessions_active_session_dir(&self) -> PathBuf {
109        self.sessions_dir().join("active")
110    }
111
112    /// Closed-session audit trail.
113    pub fn sessions_closed_dir(&self) -> PathBuf {
114        self.sessions_dir().join("closed")
115    }
116
117    /// Append-only JSONL log of rejected items across all sessions.
118    pub fn sessions_rejections_log(&self) -> PathBuf {
119        self.sessions_dir().join("rejections.log")
120    }
121
122    /// Per-kind backlog directory (`backlog/<kind>.toml`).
123    pub fn sessions_backlog_dir(&self) -> PathBuf {
124        self.sessions_dir().join("backlog")
125    }
126
127    /// Path to `aristo.toml`.
128    pub fn config_path(&self) -> PathBuf {
129        self.root.join("aristo.toml")
130    }
131
132    /// Read + parse `aristo.toml`. Returns `ConfigFile::default()` on any
133    /// failure (missing file, parse error) — the per-command config-driven
134    /// behaviors all degrade gracefully when their relevant section is
135    /// absent, so a malformed config shouldn't break read commands.
136    /// Callers that need to surface parse errors (`aristo lint`'s
137    /// `aristo.toml` validation in a future slice) should read + parse
138    /// directly.
139    #[aristo::intent(
140        "Malformed or missing aristo.toml degrades to \
141         ConfigFile::default() rather than erroring. Reader commands \
142         stay functional with project defaults when the user's config \
143         has a typo. A refactor that propagates errors here would \
144         break every reader (show / list / status / lint) at first \
145         typo. Commands that need parse errors surfaced must read and \
146         parse directly.",
147        verify = "neural",
148        id = "workspace_load_config_degrades_to_default"
149    )]
150    pub fn load_config(&self) -> aristo_core::config::ConfigFile {
151        let Ok(text) = std::fs::read_to_string(self.config_path()) else {
152            return aristo_core::config::ConfigFile::default();
153        };
154        toml::from_str(&text).unwrap_or_default()
155    }
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161    use tempfile::TempDir;
162
163    fn touch(p: &Path) {
164        std::fs::write(p, "").unwrap();
165    }
166
167    #[test]
168    fn finds_workspace_at_start_dir() {
169        let tmp = TempDir::new().unwrap();
170        touch(&tmp.path().join("aristo.toml"));
171        let ws = Workspace::find(Some(tmp.path())).unwrap();
172        // Compare on canonicalized form because macOS /tmp ↔ /private/tmp
173        // diverges between the temp-dir handle and the walked-up parent
174        // path. Workspace::find preserves the input verbatim, so we
175        // canonicalize both sides for a reliable equality check.
176        assert_eq!(
177            ws.root.canonicalize().unwrap(),
178            tmp.path().canonicalize().unwrap()
179        );
180    }
181
182    #[test]
183    fn finds_workspace_in_ancestor() {
184        let tmp = TempDir::new().unwrap();
185        touch(&tmp.path().join("aristo.toml"));
186        let nested = tmp.path().join("a/b/c/d");
187        std::fs::create_dir_all(&nested).unwrap();
188        let ws = Workspace::find(Some(&nested)).unwrap();
189        assert_eq!(
190            ws.root.canonicalize().unwrap(),
191            tmp.path().canonicalize().unwrap()
192        );
193    }
194
195    #[test]
196    fn errors_when_no_aristo_toml_in_chain() {
197        let tmp = TempDir::new().unwrap();
198        // Deliberately do NOT create aristo.toml. Walking up from a temp dir
199        // shouldn't hit any aristo.toml on a clean system. (If a developer
200        // has an aristo.toml at /tmp or /, this test would be misleading;
201        // but that's pathological enough to ignore.)
202        let nested = tmp.path().join("nope");
203        std::fs::create_dir(&nested).unwrap();
204        // We can't assert success-or-fail in general because of the above
205        // ambiguity, so just ensure: if we DO find one, it's not the temp
206        // dir we just made. (Validates that the walk reached *some* root
207        // distinct from our empty start.)
208        match Workspace::find(Some(&nested)) {
209            Err(WorkspaceError::NotFound { .. }) => {}
210            Ok(ws) => assert_ne!(
211                ws.root, nested,
212                "walk should not stop in our empty temp dir"
213            ),
214        }
215    }
216
217    #[test]
218    fn aristo_dir_paths_compose_correctly() {
219        let ws = Workspace {
220            root: PathBuf::from("/proj"),
221        };
222        assert_eq!(ws.aristo_dir(), PathBuf::from("/proj/.aristo"));
223        assert_eq!(ws.index_path(), PathBuf::from("/proj/.aristo/index.toml"));
224        assert_eq!(ws.specs_dir(), PathBuf::from("/proj/.aristo/specs"));
225        assert_eq!(ws.doc_dir(), PathBuf::from("/proj/.aristo/doc"));
226        assert_eq!(ws.config_path(), PathBuf::from("/proj/aristo.toml"));
227    }
228
229    #[test]
230    fn session_paths_compose_under_sessions_dir() {
231        let ws = Workspace {
232            root: PathBuf::from("/proj"),
233        };
234        assert_eq!(ws.sessions_dir(), PathBuf::from("/proj/.aristo/sessions"));
235        assert_eq!(
236            ws.sessions_active_pointer(),
237            PathBuf::from("/proj/.aristo/sessions/.active")
238        );
239        assert_eq!(
240            ws.sessions_active_session_dir(),
241            PathBuf::from("/proj/.aristo/sessions/active")
242        );
243        assert_eq!(
244            ws.sessions_closed_dir(),
245            PathBuf::from("/proj/.aristo/sessions/closed")
246        );
247        assert_eq!(
248            ws.sessions_rejections_log(),
249            PathBuf::from("/proj/.aristo/sessions/rejections.log")
250        );
251        assert_eq!(
252            ws.sessions_backlog_dir(),
253            PathBuf::from("/proj/.aristo/sessions/backlog")
254        );
255    }
256}