pub mod batch;
pub mod config;
pub mod decision;
pub mod history;
pub mod import_policy;
pub mod inbox;
pub use batch::TriagePlan;
pub use config::TriageConfig;
pub use decision::{
DecisionKind, DecisionOutcome, DecisionService, DuplicateTarget, Refusal, TriageDecision,
};
pub use history::TriageDecisionRecord;
use crate::model::Issue;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EntryTime<'a> {
Recorded(&'a str),
Unknown,
}
#[must_use]
pub fn entry_time(issue: &Issue) -> EntryTime<'_> {
match issue.entered_at.as_deref() {
Some(at) => EntryTime::Recorded(at),
None => EntryTime::Unknown,
}
}
#[must_use]
pub fn entry_source(issue: &Issue) -> Option<&str> {
issue.source.as_deref()
}
pub fn record_entry(issue: &mut Issue, at: &str, source: Option<&str>) {
issue.entered_at = Some(at.to_string());
issue.source = source.map(str::to_string);
}
pub fn record_entry_if_inbox(
config: &crate::config::Config,
issue: &mut Issue,
source: &str,
) -> crate::error::Result<()> {
if let Some(scope) = inbox::scope(config)? {
let landing = issue.status.display();
if landing == scope.entry_status || landing == scope.deferred_status {
record_entry(issue, &crate::model::utc_now_rfc3339(), Some(source));
}
}
Ok(())
}
pub fn record_move_entry(
config: &crate::config::Config,
issue: &mut Issue,
) -> crate::error::Result<bool> {
let before = (issue.entered_at.clone(), issue.source.clone());
record_entry_if_inbox(config, issue, "moved")?;
Ok((issue.entered_at.clone(), issue.source.clone()) != before)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{Issue, Status};
const LEGACY: &str = "---\nnumber: 1\ntitle: Legacy\nstatus: todo\nlabels: []\n\
priority: medium\nfiles: []\ncreated: 2026-01-01\n\
updated: 2026-01-01\n---\n\nBody text.";
fn legacy_issue() -> Issue {
Issue::parse(LEGACY).expect("the baseline fixture parses")
}
#[test]
fn triage_config_metadata_is_absent_on_a_legacy_issue_and_stays_that_way() {
let issue = legacy_issue();
assert_eq!(issue.entered_at, None, "no queue, no stamp");
assert_eq!(issue.source, None, "and no origin");
let out = issue.to_file_contents();
assert!(
!out.contains("entered_at") && !out.contains("source:"),
"an issue that never entered triage must not gain a key (C-001):\n{out}"
);
assert_eq!(out, LEGACY, "so a legacy file round-trips byte-identically");
}
#[test]
fn triage_config_metadata_round_trips_through_frontmatter() {
let mut issue = legacy_issue();
record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("import"));
let out = issue.to_file_contents();
assert!(
out.contains("entered_at: 2026-09-30T09:12:44Z"),
"the stamp reaches the file:\n{out}"
);
assert!(
out.contains("source: import"),
"and so does the origin:\n{out}"
);
let reloaded = Issue::parse(&out).expect("re-parses");
assert_eq!(
reloaded.entered_at.as_deref(),
Some("2026-09-30T09:12:44Z"),
"verbatim, no reformatting"
);
assert_eq!(reloaded.source.as_deref(), Some("import"));
assert_eq!(
reloaded.to_file_contents(),
out,
"and the second write is byte-identical"
);
}
#[test]
fn triage_config_metadata_entry_time_is_read_not_reconstructed() {
let mut issue = legacy_issue();
record_entry(&mut issue, "2026-09-30T09:12:44Z", None);
assert_eq!(
entry_time(&issue),
EntryTime::Recorded("2026-09-30T09:12:44Z")
);
assert_eq!(entry_source(&issue), None, "source stays optional");
}
#[test]
fn triage_config_metadata_falls_back_to_unknown_and_never_to_updated() {
let issue = legacy_issue();
assert_eq!(issue.updated, "2026-01-01", "the bait");
assert_eq!(issue.created, "2026-01-01");
match entry_time(&issue) {
EntryTime::Recorded(at) => {
panic!("a legacy issue has no entry time; `updated` offered `{at}`")
}
EntryTime::Unknown => {}
}
}
#[test]
fn triage_config_metadata_reentry_overwrites_the_stamp_and_the_source() {
let mut issue = legacy_issue();
record_entry(&mut issue, "2026-09-01T00:00:00Z", Some("new"));
record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("defer"));
assert_eq!(
entry_time(&issue),
EntryTime::Recorded("2026-09-30T09:12:44Z"),
"the newer stamp wins; keeping the oldest would measure the wrong wait"
);
assert_eq!(
entry_source(&issue),
Some("defer"),
"the source describes this entry, not the previous one"
);
record_entry(&mut issue, "2026-10-01T00:00:00Z", None);
assert_eq!(entry_source(&issue), None, "no stale origin survives");
}
#[test]
fn triage_config_metadata_recording_never_touches_the_status() {
let mut issue = legacy_issue();
let before = issue.status.clone();
assert_eq!(before, Status::new("todo"));
record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
assert_eq!(
issue.status, before,
"stamping an entry must not move the issue"
);
assert_eq!(
Issue::parse(&issue.to_file_contents()).unwrap().status,
before,
"nor may the round trip"
);
}
#[test]
fn triage_config_metadata_is_optional_and_never_a_store_row() {
let mut issue = legacy_issue();
record_entry(&mut issue, "2026-09-30T09:12:44Z", Some("new"));
let written = Issue::parse(&issue.to_file_contents()).unwrap();
assert_eq!(written.number, 1, "the issue number is unchanged");
assert_eq!(written.filename, issue.filename, "as is the source file");
assert_eq!(written.entered_at, issue.entered_at);
}
}