1use 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#[derive(Debug, Clone)]
34pub struct Workspace {
35 pub root: PathBuf,
36}
37
38impl Workspace {
39 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 pub fn aristo_dir(&self) -> PathBuf {
67 self.root.join(".aristo")
68 }
69
70 pub fn index_path(&self) -> PathBuf {
72 self.aristo_dir().join("index.toml")
73 }
74
75 pub fn canon_matches_path(&self) -> PathBuf {
81 self.aristo_dir().join("canon-matches.toml")
82 }
83
84 pub fn expectations_path(&self) -> PathBuf {
89 self.aristo_dir().join("expectations.toml")
90 }
91
92 pub fn specs_dir(&self) -> PathBuf {
94 self.aristo_dir().join("specs")
95 }
96
97 pub fn doc_dir(&self) -> PathBuf {
99 self.aristo_dir().join("doc")
100 }
101
102 pub fn sessions_dir(&self) -> PathBuf {
106 self.aristo_dir().join("sessions")
107 }
108
109 pub fn sessions_active_pointer(&self) -> PathBuf {
112 self.sessions_dir().join(".active")
113 }
114
115 pub fn sessions_active_session_dir(&self) -> PathBuf {
117 self.sessions_dir().join("active")
118 }
119
120 pub fn sessions_closed_dir(&self) -> PathBuf {
122 self.sessions_dir().join("closed")
123 }
124
125 pub fn sessions_rejections_log(&self) -> PathBuf {
127 self.sessions_dir().join("rejections.log")
128 }
129
130 pub fn sessions_backlog_dir(&self) -> PathBuf {
132 self.sessions_dir().join("backlog")
133 }
134
135 pub fn config_path(&self) -> PathBuf {
137 self.root.join("aristo.toml")
138 }
139
140 #[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) at first \
153 typo. Commands that need parse errors surfaced must read and \
154 parse directly.",
155 verify = "neural",
156 id = "workspace_load_config_degrades_to_default"
157 )]
158 pub fn load_config(&self) -> aristo_core::config::ConfigFile {
159 let Ok(text) = std::fs::read_to_string(self.config_path()) else {
160 return aristo_core::config::ConfigFile::default();
161 };
162 toml::from_str(&text).unwrap_or_default()
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use tempfile::TempDir;
170
171 fn touch(p: &Path) {
172 std::fs::write(p, "").unwrap();
173 }
174
175 #[test]
176 fn finds_workspace_at_start_dir() {
177 let tmp = TempDir::new().unwrap();
178 touch(&tmp.path().join("aristo.toml"));
179 let ws = Workspace::find(Some(tmp.path())).unwrap();
180 assert_eq!(
185 ws.root.canonicalize().unwrap(),
186 tmp.path().canonicalize().unwrap()
187 );
188 }
189
190 #[test]
191 fn finds_workspace_in_ancestor() {
192 let tmp = TempDir::new().unwrap();
193 touch(&tmp.path().join("aristo.toml"));
194 let nested = tmp.path().join("a/b/c/d");
195 std::fs::create_dir_all(&nested).unwrap();
196 let ws = Workspace::find(Some(&nested)).unwrap();
197 assert_eq!(
198 ws.root.canonicalize().unwrap(),
199 tmp.path().canonicalize().unwrap()
200 );
201 }
202
203 #[test]
204 fn errors_when_no_aristo_toml_in_chain() {
205 let tmp = TempDir::new().unwrap();
206 let nested = tmp.path().join("nope");
211 std::fs::create_dir(&nested).unwrap();
212 match Workspace::find(Some(&nested)) {
217 Err(WorkspaceError::NotFound { .. }) => {}
218 Ok(ws) => assert_ne!(
219 ws.root, nested,
220 "walk should not stop in our empty temp dir"
221 ),
222 }
223 }
224
225 #[test]
226 fn aristo_dir_paths_compose_correctly() {
227 let ws = Workspace {
228 root: PathBuf::from("/proj"),
229 };
230 assert_eq!(ws.aristo_dir(), PathBuf::from("/proj/.aristo"));
231 assert_eq!(ws.index_path(), PathBuf::from("/proj/.aristo/index.toml"));
232 assert_eq!(
233 ws.expectations_path(),
234 PathBuf::from("/proj/.aristo/expectations.toml")
235 );
236 assert_eq!(ws.specs_dir(), PathBuf::from("/proj/.aristo/specs"));
237 assert_eq!(ws.doc_dir(), PathBuf::from("/proj/.aristo/doc"));
238 assert_eq!(ws.config_path(), PathBuf::from("/proj/aristo.toml"));
239 }
240
241 #[test]
242 fn session_paths_compose_under_sessions_dir() {
243 let ws = Workspace {
244 root: PathBuf::from("/proj"),
245 };
246 assert_eq!(ws.sessions_dir(), PathBuf::from("/proj/.aristo/sessions"));
247 assert_eq!(
248 ws.sessions_active_pointer(),
249 PathBuf::from("/proj/.aristo/sessions/.active")
250 );
251 assert_eq!(
252 ws.sessions_active_session_dir(),
253 PathBuf::from("/proj/.aristo/sessions/active")
254 );
255 assert_eq!(
256 ws.sessions_closed_dir(),
257 PathBuf::from("/proj/.aristo/sessions/closed")
258 );
259 assert_eq!(
260 ws.sessions_rejections_log(),
261 PathBuf::from("/proj/.aristo/sessions/rejections.log")
262 );
263 assert_eq!(
264 ws.sessions_backlog_dir(),
265 PathBuf::from("/proj/.aristo/sessions/backlog")
266 );
267 }
268}