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/expectations.toml` — the user-side known-failure
85    /// waiver sidecar (Phase 16 (c); committed by default). See
86    /// `aristo_core::expectations` for the schema. Written by
87    /// `aristo verify --accept`; read at verify time as a join.
88    pub fn expectations_path(&self) -> PathBuf {
89        self.aristo_dir().join("expectations.toml")
90    }
91
92    /// Path to `.aristo/specs/`.
93    pub fn specs_dir(&self) -> PathBuf {
94        self.aristo_dir().join("specs")
95    }
96
97    /// Path to `.aristo/doc/`.
98    pub fn doc_dir(&self) -> PathBuf {
99        self.aristo_dir().join("doc")
100    }
101
102    /// Path to `.aristo/sessions/`. Holds the review-session substrate's
103    /// local-only state — see `docs/decisions/review-sessions.md` §D5.
104    /// Everything under this directory is gitignored.
105    pub fn sessions_dir(&self) -> PathBuf {
106        self.aristo_dir().join("sessions")
107    }
108
109    /// Pointer file holding the active session id (if any). Existence
110    /// implies an active session; missing means none.
111    pub fn sessions_active_pointer(&self) -> PathBuf {
112        self.sessions_dir().join(".active")
113    }
114
115    /// Per-session TOML state for in-flight sessions.
116    pub fn sessions_active_session_dir(&self) -> PathBuf {
117        self.sessions_dir().join("active")
118    }
119
120    /// Closed-session audit trail.
121    pub fn sessions_closed_dir(&self) -> PathBuf {
122        self.sessions_dir().join("closed")
123    }
124
125    /// Append-only JSONL log of rejected items across all sessions.
126    pub fn sessions_rejections_log(&self) -> PathBuf {
127        self.sessions_dir().join("rejections.log")
128    }
129
130    /// Per-kind backlog directory (`backlog/<kind>.toml`).
131    pub fn sessions_backlog_dir(&self) -> PathBuf {
132        self.sessions_dir().join("backlog")
133    }
134
135    /// Path to `aristo.toml`.
136    pub fn config_path(&self) -> PathBuf {
137        self.root.join("aristo.toml")
138    }
139
140    /// Read + parse `aristo.toml`. Returns `ConfigFile::default()` on any
141    /// failure (missing file, parse error) — the per-command config-driven
142    /// behaviors all degrade gracefully when their relevant section is
143    /// absent, so a malformed config shouldn't break read commands.
144    /// Callers that need to surface parse errors (`aristo lint`'s
145    /// `aristo.toml` validation in a future slice) should read + parse
146    /// directly.
147    #[aristo::intent(
148        "Malformed or missing aristo.toml degrades to \
149         ConfigFile::default() rather than erroring. Reader commands \
150         stay functional with project defaults when the user's config \
151         has a typo. A refactor that propagates errors here would \
152         break every reader (show / list / status / lint) whenever the \
153         config has any parse error. Commands that need parse errors \
154         surfaced must read and \
155         parse directly.",
156        verify = "neural",
157        id = "workspace_load_config_degrades_to_default"
158    )]
159    pub fn load_config(&self) -> aristo_core::config::ConfigFile {
160        let Ok(text) = std::fs::read_to_string(self.config_path()) else {
161            return aristo_core::config::ConfigFile::default();
162        };
163        toml::from_str(&text).unwrap_or_default()
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use tempfile::TempDir;
171
172    fn touch(p: &Path) {
173        std::fs::write(p, "").unwrap();
174    }
175
176    #[test]
177    fn finds_workspace_at_start_dir() {
178        let tmp = TempDir::new().unwrap();
179        touch(&tmp.path().join("aristo.toml"));
180        let ws = Workspace::find(Some(tmp.path())).unwrap();
181        // Compare on canonicalized form because macOS /tmp ↔ /private/tmp
182        // diverges between the temp-dir handle and the walked-up parent
183        // path. Workspace::find preserves the input verbatim, so we
184        // canonicalize both sides for a reliable equality check.
185        assert_eq!(
186            ws.root.canonicalize().unwrap(),
187            tmp.path().canonicalize().unwrap()
188        );
189    }
190
191    #[test]
192    fn finds_workspace_in_ancestor() {
193        let tmp = TempDir::new().unwrap();
194        touch(&tmp.path().join("aristo.toml"));
195        let nested = tmp.path().join("a/b/c/d");
196        std::fs::create_dir_all(&nested).unwrap();
197        let ws = Workspace::find(Some(&nested)).unwrap();
198        assert_eq!(
199            ws.root.canonicalize().unwrap(),
200            tmp.path().canonicalize().unwrap()
201        );
202    }
203
204    #[test]
205    fn errors_when_no_aristo_toml_in_chain() {
206        let tmp = TempDir::new().unwrap();
207        // Deliberately do NOT create aristo.toml. Walking up from a temp dir
208        // shouldn't hit any aristo.toml on a clean system. (If a developer
209        // has an aristo.toml at /tmp or /, this test would be misleading;
210        // but that's pathological enough to ignore.)
211        let nested = tmp.path().join("nope");
212        std::fs::create_dir(&nested).unwrap();
213        // We can't assert success-or-fail in general because of the above
214        // ambiguity, so just ensure: if we DO find one, it's not the temp
215        // dir we just made. (Validates that the walk reached *some* root
216        // distinct from our empty start.)
217        match Workspace::find(Some(&nested)) {
218            Err(WorkspaceError::NotFound { .. }) => {}
219            Ok(ws) => assert_ne!(
220                ws.root, nested,
221                "walk should not stop in our empty temp dir"
222            ),
223        }
224    }
225
226    #[test]
227    fn aristo_dir_paths_compose_correctly() {
228        let ws = Workspace {
229            root: PathBuf::from("/proj"),
230        };
231        assert_eq!(ws.aristo_dir(), PathBuf::from("/proj/.aristo"));
232        assert_eq!(ws.index_path(), PathBuf::from("/proj/.aristo/index.toml"));
233        assert_eq!(
234            ws.expectations_path(),
235            PathBuf::from("/proj/.aristo/expectations.toml")
236        );
237        assert_eq!(ws.specs_dir(), PathBuf::from("/proj/.aristo/specs"));
238        assert_eq!(ws.doc_dir(), PathBuf::from("/proj/.aristo/doc"));
239        assert_eq!(ws.config_path(), PathBuf::from("/proj/aristo.toml"));
240    }
241
242    #[test]
243    fn session_paths_compose_under_sessions_dir() {
244        let ws = Workspace {
245            root: PathBuf::from("/proj"),
246        };
247        assert_eq!(ws.sessions_dir(), PathBuf::from("/proj/.aristo/sessions"));
248        assert_eq!(
249            ws.sessions_active_pointer(),
250            PathBuf::from("/proj/.aristo/sessions/.active")
251        );
252        assert_eq!(
253            ws.sessions_active_session_dir(),
254            PathBuf::from("/proj/.aristo/sessions/active")
255        );
256        assert_eq!(
257            ws.sessions_closed_dir(),
258            PathBuf::from("/proj/.aristo/sessions/closed")
259        );
260        assert_eq!(
261            ws.sessions_rejections_log(),
262            PathBuf::from("/proj/.aristo/sessions/rejections.log")
263        );
264        assert_eq!(
265            ws.sessions_backlog_dir(),
266            PathBuf::from("/proj/.aristo/sessions/backlog")
267        );
268    }
269}