use anyhow::Result;
use chrono::Utc;
use shiplog::ports::{IngestOutput, Ingestor};
use shiplog::schema::coverage::{Completeness, CoverageManifest, CoverageSlice, TimeWindow};
use shiplog::schema::freshness::{FreshnessStatus, SourceFreshness};
use std::path::Path;
pub mod events;
pub use events::{
create_empty_file, create_entry, entry_date_range, entry_to_event, events_in_window,
read_manual_events, write_manual_events,
};
pub struct ManualIngestor {
pub events_path: std::path::PathBuf,
pub user: String,
pub window: TimeWindow,
}
impl ManualIngestor {
pub fn new(
events_path: impl AsRef<Path>,
user: String,
since: chrono::NaiveDate,
until: chrono::NaiveDate,
) -> Self {
Self {
events_path: events_path.as_ref().to_path_buf(),
user,
window: TimeWindow { since, until },
}
}
}
impl Ingestor for ManualIngestor {
fn ingest(&self) -> Result<IngestOutput> {
if !self.events_path.exists() {
let observed_at = Utc::now();
return Ok(IngestOutput {
events: Vec::new(),
coverage: CoverageManifest {
run_id: shiplog::ids::RunId::now("manual"),
generated_at: observed_at,
user: self.user.clone(),
window: self.window.clone(),
mode: "manual".to_string(),
sources: vec!["manual".to_string()],
slices: vec![CoverageSlice {
window: self.window.clone(),
query: format!("file:{:?}", self.events_path),
total_count: 0,
fetched: 0,
incomplete_results: Some(false),
notes: vec!["manual_events_file_not_found".to_string()],
}],
warnings: vec![format!(
"Manual events file not found: {:?}",
self.events_path
)],
completeness: Completeness::Unknown,
},
freshness: vec![SourceFreshness {
source: "manual".to_string(),
status: FreshnessStatus::Unavailable,
cache_hits: 0,
cache_misses: 0,
fetched_at: Some(observed_at),
reason: Some(format!(
"manual events file not found at {:?}",
self.events_path
)),
}],
});
}
let file = read_manual_events(&self.events_path)?;
let (events, warnings) = events_in_window(&file.events, &self.user, &self.window);
let observed_at = Utc::now();
let coverage = CoverageManifest {
run_id: shiplog::ids::RunId::now("manual"),
generated_at: observed_at,
user: self.user.clone(),
window: self.window.clone(),
mode: "manual".to_string(),
sources: vec!["manual".to_string()],
slices: vec![CoverageSlice {
window: self.window.clone(),
query: format!("file:{:?}", self.events_path),
total_count: file.events.len() as u64,
fetched: events.len() as u64,
incomplete_results: Some(false),
notes: vec!["manual_events".to_string()],
}],
warnings,
completeness: Completeness::Complete,
};
let freshness = vec![SourceFreshness {
source: "manual".to_string(),
status: FreshnessStatus::Fresh,
cache_hits: 0,
cache_misses: 0,
fetched_at: Some(observed_at),
reason: None,
}];
Ok(IngestOutput {
events,
coverage,
freshness,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
use shiplog::schema::event::{ManualDate, ManualEventEntry, ManualEventType, ManualEventsFile};
fn make_test_entry(id: &str) -> ManualEventEntry {
ManualEventEntry {
id: id.to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
title: "Test Event".to_string(),
description: Some("A test event".to_string()),
workstream: Some("test-workstream".to_string()),
tags: vec!["test".to_string()],
receipts: vec![shiplog::schema::event::Link {
label: "doc".to_string(),
url: "https://example.com/doc".to_string(),
}],
impact: Some("Made things better".to_string()),
}
}
#[test]
fn reads_and_writes_manual_events() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![make_test_entry("test-1")],
};
write_manual_events(&path, &file).unwrap();
let read = read_manual_events(&path).unwrap();
assert_eq!(read.events.len(), 1);
assert_eq!(read.events[0].id, "test-1");
}
#[test]
fn ingest_filters_by_date() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![
ManualEventEntry {
id: "inside".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 3, 15).unwrap()),
title: "Inside".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
},
ManualEventEntry {
id: "outside".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 6, 15).unwrap()),
title: "Outside".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
},
],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 1);
assert_eq!(
output.events[0].source.opaque_id,
Some("inside".to_string())
);
assert_eq!(
output.freshness.len(),
1,
"manual ingest must emit exactly one freshness receipt per run"
);
let entry = &output.freshness[0];
assert_eq!(entry.source, "manual");
assert!(matches!(entry.status, FreshnessStatus::Fresh));
assert_eq!(entry.cache_hits, 0);
assert_eq!(entry.cache_misses, 0);
assert!(entry.fetched_at.is_some());
assert!(entry.reason.is_none());
}
#[test]
fn ingest_missing_events_file_emits_unavailable_freshness() -> anyhow::Result<()> {
let temp = tempfile::tempdir()?;
let path = temp.path().join("does_not_exist.yaml");
let since = NaiveDate::from_ymd_opt(2025, 1, 1)
.ok_or_else(|| anyhow::anyhow!("since date construction"))?;
let until = NaiveDate::from_ymd_opt(2025, 4, 1)
.ok_or_else(|| anyhow::anyhow!("until date construction"))?;
let ing = ManualIngestor::new(&path, "testuser".to_string(), since, until);
let output = ing.ingest()?;
assert_eq!(output.events.len(), 0);
assert_eq!(output.freshness.len(), 1);
let entry = &output.freshness[0];
assert_eq!(entry.source, "manual");
assert!(matches!(entry.status, FreshnessStatus::Unavailable));
assert!(entry.reason.is_some());
Ok(())
}
#[test]
fn event_with_end_date_equal_to_window_since_is_included() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "boundary".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 3, 1).unwrap()),
title: "Boundary Event".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 1);
}
#[test]
fn event_ending_before_window_is_excluded() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "before".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 2, 28).unwrap()),
title: "Before Window".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 0);
}
#[test]
fn event_starting_at_window_until_is_excluded() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "at-until".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 4, 1).unwrap()),
title: "At Until Boundary".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 0);
}
#[test]
fn event_spanning_before_window_start_triggers_warning() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "span-before".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Range {
start: NaiveDate::from_ymd_opt(2025, 2, 15).unwrap(),
end: NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(),
},
title: "Spans Before".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 1);
assert!(
output
.coverage
.warnings
.iter()
.any(|w| w.contains("partially outside"))
);
}
#[test]
fn event_spanning_after_window_end_triggers_warning() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "span-after".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Range {
start: NaiveDate::from_ymd_opt(2025, 3, 15).unwrap(),
end: NaiveDate::from_ymd_opt(2025, 4, 15).unwrap(),
},
title: "Spans After".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 1);
assert!(
output
.coverage
.warnings
.iter()
.any(|w| w.contains("partially outside"))
);
}
#[test]
fn event_entirely_outside_window_excluded() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "outside".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Range {
start: NaiveDate::from_ymd_opt(2025, 5, 1).unwrap(),
end: NaiveDate::from_ymd_opt(2025, 6, 1).unwrap(),
},
title: "Entirely Outside".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(output.events.len(), 0);
assert!(output.coverage.warnings.is_empty());
}
#[test]
fn handles_missing_file() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("nonexistent.yaml");
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 1, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert!(output.events.is_empty());
assert!(!output.coverage.warnings.is_empty());
}
#[test]
fn event_ending_one_day_before_window_since_is_excluded() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("manual_events.yaml");
let file = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "day-before".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 2, 28).unwrap()),
title: "Day Before Window".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file).unwrap();
let ing = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output = ing.ingest().unwrap();
assert_eq!(
output.events.len(),
0,
"event ending before window.since must be excluded"
);
let file2 = ManualEventsFile {
version: 1,
generated_at: Utc::now(),
events: vec![ManualEventEntry {
id: "at-since".to_string(),
event_type: ManualEventType::Note,
date: ManualDate::Single(NaiveDate::from_ymd_opt(2025, 3, 1).unwrap()),
title: "At Window Since".to_string(),
description: None,
workstream: None,
tags: vec![],
receipts: vec![],
impact: None,
}],
};
write_manual_events(&path, &file2).unwrap();
let ing2 = ManualIngestor::new(
&path,
"testuser".to_string(),
NaiveDate::from_ymd_opt(2025, 3, 1).unwrap(),
NaiveDate::from_ymd_opt(2025, 4, 1).unwrap(),
);
let output2 = ing2.ingest().unwrap();
assert_eq!(
output2.events.len(),
1,
"event on window.since boundary must be included"
);
}
}