1use anyhow::{Context, Result};
10use shiplog_ports::WorkstreamClusterer;
11use shiplog_schema::event::EventEnvelope;
12use shiplog_schema::workstream::WorkstreamsFile;
13use std::path::{Path, PathBuf};
14
15pub const CURATED_FILENAME: &str = "workstreams.yaml";
17
18pub const SUGGESTED_FILENAME: &str = "workstreams.suggested.yaml";
20
21pub 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
45pub 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
64pub struct WorkstreamManager;
70
71impl WorkstreamManager {
72 pub const SUGGESTED_FILENAME: &'static str = SUGGESTED_FILENAME;
74
75 pub const CURATED_FILENAME: &'static str = CURATED_FILENAME;
77
78 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 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 pub fn has_curated(out_dir: &Path) -> bool {
149 Self::curated_path(out_dir).exists()
150 }
151
152 pub fn curated_path(out_dir: &Path) -> PathBuf {
164 out_dir.join(Self::CURATED_FILENAME)
165 }
166
167 pub fn suggested_path(out_dir: &Path) -> PathBuf {
179 out_dir.join(Self::SUGGESTED_FILENAME)
180 }
181
182 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}