Skip to main content

shiplog_workstreams/
layout.rs

1//! Workstream artifact path contracts and file precedence logic.
2//!
3//! This module owns the loading/saving rules for workstream files:
4//! - `workstreams.yaml` (curated, user-owned)
5//! - `workstreams.suggested.yaml` (machine-generated)
6//!
7//! It intentionally has one responsibility: stable workstream-file semantics.
8
9use anyhow::{Context, Result};
10use shiplog_ports::WorkstreamClusterer;
11use shiplog_schema::event::EventEnvelope;
12use shiplog_schema::workstream::WorkstreamsFile;
13use std::path::{Path, PathBuf};
14
15/// User-curated workstream file.
16pub const CURATED_FILENAME: &str = "workstreams.yaml";
17
18/// Machine-generated suggested workstream file.
19pub const SUGGESTED_FILENAME: &str = "workstreams.suggested.yaml";
20
21/// Load an existing YAML file if present, otherwise run clustering.
22///
23/// # Examples
24///
25/// Fall back to clustering when no file is given:
26///
27/// ```
28/// use shiplog_workstreams::load_or_cluster;
29/// use shiplog_workstreams::RepoClusterer;
30///
31/// let ws = load_or_cluster(None, &RepoClusterer, &[]).unwrap();
32/// assert!(ws.workstreams.is_empty());
33/// ```
34pub fn load_or_cluster(
35    maybe_yaml: Option<&Path>,
36    clusterer: &dyn WorkstreamClusterer,
37    events: &[EventEnvelope],
38) -> Result<WorkstreamsFile> {
39    if let Some(path) = maybe_yaml.filter(|path| path.exists()) {
40        return read_workstreams(path);
41    }
42    clusterer.cluster(events)
43}
44
45/// Write a `WorkstreamsFile` as YAML.
46///
47/// # Examples
48///
49/// ```
50/// # use chrono::Utc;
51/// use shiplog_workstreams::write_workstreams;
52/// use shiplog_schema::workstream::WorkstreamsFile;
53///
54/// let ws = WorkstreamsFile { version: 1, generated_at: Utc::now(), workstreams: vec![] };
55/// let dir = tempfile::tempdir().unwrap();
56/// write_workstreams(&dir.path().join("ws.yaml"), &ws).unwrap();
57/// ```
58pub fn write_workstreams(path: &Path, workstreams: &WorkstreamsFile) -> Result<()> {
59    let yaml = serde_yaml::to_string(workstreams)?;
60    std::fs::write(path, yaml).with_context(|| format!("write workstreams to {path:?}"))?;
61    Ok(())
62}
63
64/// Two-layer workstream file lifecycle:
65///
66/// 1. `workstreams.yaml` = curated (user-owned, highest priority)
67/// 2. `workstreams.suggested.yaml` = generated suggestions
68/// 3. If neither exists, generate from events and write suggestions
69pub struct WorkstreamManager;
70
71impl WorkstreamManager {
72    /// Suggested file name (machine-generated proposals)
73    pub const SUGGESTED_FILENAME: &'static str = SUGGESTED_FILENAME;
74
75    /// Curated file name (user-owned source of truth)
76    pub const CURATED_FILENAME: &'static str = CURATED_FILENAME;
77
78    /// Load the effective workstreams for rendering.
79    ///
80    /// Priority:
81    /// 1. `workstreams.yaml` if present
82    /// 2. `workstreams.suggested.yaml` if present
83    /// 3. generate via clusterer and persist suggested file
84    ///
85    /// # Examples
86    ///
87    /// When no files exist, clustering runs and writes `workstreams.suggested.yaml`:
88    ///
89    /// ```
90    /// use shiplog_workstreams::WorkstreamManager;
91    /// use shiplog_workstreams::RepoClusterer;
92    ///
93    /// let dir = tempfile::tempdir().unwrap();
94    /// let ws = WorkstreamManager::load_effective(dir.path(), &RepoClusterer, &[]).unwrap();
95    /// assert!(ws.workstreams.is_empty());
96    /// assert!(WorkstreamManager::suggested_path(dir.path()).exists());
97    /// ```
98    pub fn load_effective(
99        out_dir: &Path,
100        clusterer: &dyn WorkstreamClusterer,
101        events: &[EventEnvelope],
102    ) -> Result<WorkstreamsFile> {
103        let curated_path = Self::curated_path(out_dir);
104        if curated_path.exists() {
105            return read_workstreams(&curated_path);
106        }
107
108        let suggested_path = Self::suggested_path(out_dir);
109        if suggested_path.exists() {
110            return read_workstreams(&suggested_path);
111        }
112
113        let ws = clusterer.cluster(events)?;
114        write_workstreams(&suggested_path, &ws)?;
115        Ok(ws)
116    }
117
118    /// Write machine-generated suggested workstreams.
119    /// This always overwrites `workstreams.suggested.yaml`.
120    ///
121    /// # Examples
122    ///
123    /// ```
124    /// # use chrono::Utc;
125    /// use shiplog_workstreams::WorkstreamManager;
126    /// use shiplog_schema::workstream::WorkstreamsFile;
127    ///
128    /// let ws = WorkstreamsFile { version: 1, generated_at: Utc::now(), workstreams: vec![] };
129    /// let dir = tempfile::tempdir().unwrap();
130    /// WorkstreamManager::write_suggested(dir.path(), &ws).unwrap();
131    /// assert!(WorkstreamManager::suggested_path(dir.path()).exists());
132    /// ```
133    pub fn write_suggested(out_dir: &Path, workstreams: &WorkstreamsFile) -> Result<()> {
134        let path = Self::suggested_path(out_dir);
135        write_workstreams(&path, workstreams)
136    }
137
138    /// Check whether the curated workstreams file exists.
139    ///
140    /// # Examples
141    ///
142    /// ```
143    /// use shiplog_workstreams::WorkstreamManager;
144    ///
145    /// let dir = tempfile::tempdir().unwrap();
146    /// assert!(!WorkstreamManager::has_curated(dir.path()));
147    /// ```
148    pub fn has_curated(out_dir: &Path) -> bool {
149        Self::curated_path(out_dir).exists()
150    }
151
152    /// Get the curated file path.
153    ///
154    /// # Examples
155    ///
156    /// ```
157    /// use shiplog_workstreams::WorkstreamManager;
158    /// use std::path::Path;
159    ///
160    /// let path = WorkstreamManager::curated_path(Path::new("./out/run_1"));
161    /// assert!(path.ends_with("workstreams.yaml"));
162    /// ```
163    pub fn curated_path(out_dir: &Path) -> PathBuf {
164        out_dir.join(Self::CURATED_FILENAME)
165    }
166
167    /// Get the suggested file path.
168    ///
169    /// # Examples
170    ///
171    /// ```
172    /// use shiplog_workstreams::WorkstreamManager;
173    /// use std::path::Path;
174    ///
175    /// let path = WorkstreamManager::suggested_path(Path::new("./out/run_1"));
176    /// assert!(path.ends_with("workstreams.suggested.yaml"));
177    /// ```
178    pub fn suggested_path(out_dir: &Path) -> PathBuf {
179        out_dir.join(Self::SUGGESTED_FILENAME)
180    }
181
182    /// Try to load curated then suggested workstreams.
183    ///
184    /// Returns `None` when neither file exists.
185    ///
186    /// # Examples
187    ///
188    /// ```
189    /// use shiplog_workstreams::WorkstreamManager;
190    ///
191    /// let dir = tempfile::tempdir().unwrap();
192    /// assert!(WorkstreamManager::try_load(dir.path()).unwrap().is_none());
193    /// ```
194    pub fn try_load(out_dir: &Path) -> Result<Option<WorkstreamsFile>> {
195        let curated_path = Self::curated_path(out_dir);
196        if curated_path.exists() {
197            return Ok(Some(read_workstreams(&curated_path)?));
198        }
199
200        let suggested_path = Self::suggested_path(out_dir);
201        if suggested_path.exists() {
202            return Ok(Some(read_workstreams(&suggested_path)?));
203        }
204
205        Ok(None)
206    }
207}
208
209fn read_workstreams(path: &Path) -> Result<WorkstreamsFile> {
210    let text =
211        std::fs::read_to_string(path).with_context(|| format!("read workstreams from {path:?}"))?;
212    let workstreams: WorkstreamsFile =
213        serde_yaml::from_str(&text).with_context(|| format!("parse workstreams yaml {path:?}"))?;
214    Ok(workstreams)
215}
216
217#[cfg(test)]
218mod tests {
219    use super::*;
220    use chrono::Utc;
221    use shiplog_ids::{EventId, WorkstreamId};
222    use shiplog_ports::WorkstreamClusterer;
223    use shiplog_schema::event::{
224        Actor, EventEnvelope, EventKind, EventPayload, Link, RepoRef, RepoVisibility, ReviewEvent,
225        SourceRef, SourceSystem,
226    };
227    use shiplog_schema::workstream::{Workstream, WorkstreamStats};
228    use tempfile::tempdir;
229
230    fn make_event(repo_name: &str, event_id: &str, number: u64) -> EventEnvelope {
231        EventEnvelope {
232            id: EventId::from_parts(["test", event_id]),
233            kind: EventKind::PullRequest,
234            occurred_at: Utc::now(),
235            actor: Actor {
236                login: "actor".into(),
237                id: None,
238            },
239            repo: RepoRef {
240                full_name: repo_name.into(),
241                html_url: Some(format!("https://example.com/{repo_name}")),
242                visibility: RepoVisibility::Unknown,
243            },
244            payload: EventPayload::Review(ReviewEvent {
245                pull_number: number,
246                pull_title: "A review".into(),
247                submitted_at: Utc::now(),
248                state: "approved".into(),
249                window: None,
250            }),
251            tags: vec![],
252            links: vec![Link {
253                label: "review".into(),
254                url: format!("https://example.com/{repo_name}/reviews/{number}"),
255            }],
256            source: SourceRef {
257                system: SourceSystem::Github,
258                url: Some("https://api.example.com".into()),
259                opaque_id: None,
260            },
261        }
262    }
263
264    fn make_workstreams(title: &str, repo: &str) -> WorkstreamsFile {
265        WorkstreamsFile {
266            version: 1,
267            generated_at: Utc::now(),
268            workstreams: vec![Workstream {
269                id: WorkstreamId::from_parts(["repo", repo]),
270                title: title.to_string(),
271                summary: Some("test".into()),
272                tags: vec!["repo".into()],
273                stats: WorkstreamStats::zero(),
274                events: vec![],
275                receipts: vec![],
276            }],
277        }
278    }
279
280    struct FakeClusterer;
281    impl WorkstreamClusterer for FakeClusterer {
282        fn cluster(&self, _events: &[EventEnvelope]) -> anyhow::Result<WorkstreamsFile> {
283            Ok(make_workstreams("fallback", "fallback"))
284        }
285    }
286
287    #[test]
288    fn load_or_cluster_prefers_existing_yaml() {
289        let temp_dir = tempdir().unwrap();
290        let path = temp_dir.path().join("existing.yaml");
291        let workstreams = make_workstreams("existing", "repo/ex");
292        write_workstreams(&path, &workstreams).unwrap();
293
294        let loaded = load_or_cluster(Some(&path), &FakeClusterer, &[]).unwrap();
295        assert_eq!(loaded.workstreams[0].title, "existing");
296    }
297
298    #[test]
299    fn load_or_cluster_falls_back_to_clusterer() {
300        let loaded = load_or_cluster(None, &FakeClusterer, &[]).unwrap();
301        assert_eq!(loaded.workstreams[0].title, "fallback");
302    }
303
304    #[test]
305    fn load_effective_prefers_curated_over_suggested() {
306        let temp_dir = tempdir().unwrap();
307        let curated = temp_dir.path().join(CURATED_FILENAME);
308        let suggested = temp_dir.path().join(SUGGESTED_FILENAME);
309        write_workstreams(&curated, &make_workstreams("curated", "repo/c")).unwrap();
310        write_workstreams(&suggested, &make_workstreams("suggested", "repo/s")).unwrap();
311
312        let loaded =
313            WorkstreamManager::load_effective(temp_dir.path(), &FakeClusterer, &[]).unwrap();
314        assert_eq!(loaded.workstreams[0].title, "curated");
315        assert_eq!(curated, WorkstreamManager::curated_path(temp_dir.path()));
316    }
317
318    #[test]
319    fn load_effective_falls_back_to_suggested() {
320        let temp_dir = tempdir().unwrap();
321        let suggested = temp_dir.path().join(SUGGESTED_FILENAME);
322        write_workstreams(&suggested, &make_workstreams("suggested", "repo/s")).unwrap();
323
324        let loaded =
325            WorkstreamManager::load_effective(temp_dir.path(), &FakeClusterer, &[]).unwrap();
326        assert_eq!(loaded.workstreams[0].title, "suggested");
327    }
328
329    #[test]
330    fn load_effective_generates_when_missing() {
331        let temp_dir = tempdir().unwrap();
332        let loaded = WorkstreamManager::load_effective(
333            temp_dir.path(),
334            &FakeClusterer,
335            &[make_event("repo/a", "1", 1)],
336        )
337        .unwrap();
338        assert_eq!(loaded.workstreams[0].title, "fallback");
339        assert!(WorkstreamManager::suggested_path(temp_dir.path()).exists());
340    }
341
342    #[test]
343    fn try_load_respects_precedence() {
344        let temp_dir = tempdir().unwrap();
345        let curated = temp_dir.path().join(CURATED_FILENAME);
346        let suggested = temp_dir.path().join(SUGGESTED_FILENAME);
347        write_workstreams(&suggested, &make_workstreams("suggested", "repo/s")).unwrap();
348        write_workstreams(&curated, &make_workstreams("curated", "repo/c")).unwrap();
349
350        let loaded = WorkstreamManager::try_load(temp_dir.path())
351            .unwrap()
352            .unwrap();
353        assert_eq!(loaded.workstreams[0].title, "curated");
354    }
355
356    #[test]
357    fn has_curated_checks_file_presence() {
358        let temp_dir = tempdir().unwrap();
359        assert!(!WorkstreamManager::has_curated(temp_dir.path()));
360
361        write_workstreams(
362            &WorkstreamManager::curated_path(temp_dir.path()),
363            &make_workstreams("curated", "repo"),
364        )
365        .unwrap();
366        assert!(WorkstreamManager::has_curated(temp_dir.path()));
367    }
368
369    #[test]
370    fn curated_path_uses_correct_filename() {
371        let dir = Path::new("/some/dir");
372        let path = WorkstreamManager::curated_path(dir);
373        assert_eq!(path.file_name().unwrap(), CURATED_FILENAME);
374    }
375
376    #[test]
377    fn suggested_path_uses_correct_filename() {
378        let dir = Path::new("/some/dir");
379        let path = WorkstreamManager::suggested_path(dir);
380        assert_eq!(path.file_name().unwrap(), SUGGESTED_FILENAME);
381    }
382
383    #[test]
384    fn write_suggested_writes_to_correct_path() {
385        let temp_dir = tempdir().unwrap();
386        let ws = make_workstreams("suggested-write", "repo/sw");
387        WorkstreamManager::write_suggested(temp_dir.path(), &ws).unwrap();
388
389        let suggested_path = WorkstreamManager::suggested_path(temp_dir.path());
390        assert!(suggested_path.exists());
391
392        let loaded = read_workstreams(&suggested_path).unwrap();
393        assert_eq!(loaded.workstreams[0].title, "suggested-write");
394    }
395
396    #[test]
397    fn try_load_returns_none_when_empty() {
398        let temp_dir = tempdir().unwrap();
399        let result = WorkstreamManager::try_load(temp_dir.path()).unwrap();
400        assert!(result.is_none());
401    }
402
403    #[test]
404    fn load_or_cluster_with_nonexistent_path_falls_back() {
405        let non_existent = Path::new("/does/not/exist/workstreams.yaml");
406        let loaded = load_or_cluster(Some(non_existent), &FakeClusterer, &[]).unwrap();
407        assert_eq!(loaded.workstreams[0].title, "fallback");
408    }
409
410    #[test]
411    fn write_read_roundtrip_preserves_empty_workstreams() {
412        let temp_dir = tempdir().unwrap();
413        let ws = WorkstreamsFile {
414            version: 1,
415            generated_at: Utc::now(),
416            workstreams: vec![],
417        };
418        let path = temp_dir.path().join("empty.yaml");
419        write_workstreams(&path, &ws).unwrap();
420        let loaded = read_workstreams(&path).unwrap();
421        assert!(loaded.workstreams.is_empty());
422        assert_eq!(loaded.version, 1);
423    }
424
425    #[test]
426    fn write_suggested_overwrites_existing() {
427        let temp_dir = tempdir().unwrap();
428        let ws1 = make_workstreams("first", "repo/first");
429        let ws2 = make_workstreams("second", "repo/second");
430
431        WorkstreamManager::write_suggested(temp_dir.path(), &ws1).unwrap();
432        WorkstreamManager::write_suggested(temp_dir.path(), &ws2).unwrap();
433
434        let loaded = read_workstreams(&WorkstreamManager::suggested_path(temp_dir.path())).unwrap();
435        assert_eq!(loaded.workstreams[0].title, "second");
436    }
437}