mod hunks;
mod note;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::engine::UntrustedKind;
pub use hunks::added_lines;
pub use note::{AuditHead, Discrepancy, EventHash, NOTE_FORMAT, Note, NoteFormat, compare};
pub const CLOCK_SLACK_MS: i64 = 2_000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LineRange {
pub start: u32,
pub end: u32,
}
impl LineRange {
#[must_use]
pub fn len(self) -> u32 {
self.end.saturating_sub(self.start).saturating_add(1)
}
#[must_use]
pub fn is_empty(self) -> bool {
false
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Untrusted {
pub kind: UntrustedKind,
pub source: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AgentWrite {
pub path: String,
pub host: String,
pub session: String,
pub event: i64,
pub ts_ms: i64,
pub sha256: Option<String>,
pub before: Option<String>,
pub untrusted: Option<Untrusted>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ChangedFile {
pub path: String,
pub before: Option<String>,
pub after: Option<String>,
pub added: Vec<LineRange>,
}
#[derive(Debug, Clone, Copy)]
pub struct Commit<'a> {
pub parent_ms: Option<i64>,
pub commit_ms: i64,
pub files: &'a [ChangedFile],
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Evidence {
Lines,
Content,
Touched,
}
impl Evidence {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Lines => "lines",
Self::Content => "content",
Self::Touched => "touched",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FileRecord {
pub path: String,
pub host: String,
pub session: String,
pub event: i64,
pub ts_ms: i64,
pub evidence: Evidence,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<LineRange>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub untrusted: Option<Untrusted>,
}
impl FileRecord {
#[must_use]
pub fn line_count(&self) -> u64 {
self.lines.iter().map(|r| u64::from(r.len())).sum()
}
}
#[must_use]
pub fn attribute(commit: &Commit<'_>, writes: &[AgentWrite]) -> Vec<FileRecord> {
let until = commit.commit_ms.saturating_add(CLOCK_SLACK_MS);
let mut out = Vec::new();
for file in commit.files {
let mut newest: BTreeMap<(&str, &str), &AgentWrite> = BTreeMap::new();
for write in writes
.iter()
.filter(|w| w.path == file.path && w.ts_ms <= until)
{
let slot = newest
.entry((write.host.as_str(), write.session.as_str()))
.or_insert(write);
if write.event > slot.event {
*slot = write;
}
}
for write in newest.into_values() {
if let Some(evidence) = evidence(commit, file, write) {
out.push(FileRecord {
path: file.path.clone(),
host: write.host.clone(),
session: write.session.clone(),
event: write.event,
ts_ms: write.ts_ms,
evidence,
lines: if evidence == Evidence::Lines {
file.added.clone()
} else {
Vec::new()
},
untrusted: write.untrusted.clone(),
});
}
}
}
out.sort_by(|a, b| (&a.path, a.event).cmp(&(&b.path, b.event)));
out
}
fn evidence(commit: &Commit<'_>, file: &ChangedFile, write: &AgentWrite) -> Option<Evidence> {
let after = file.after.as_deref().filter(|h| !h.is_empty());
if let (Some(after), Some(wrote)) = (after, write.sha256.as_deref())
&& after == wrote
{
let whole = file.before.is_some() && file.before == write.before;
return Some(if whole {
Evidence::Lines
} else {
Evidence::Content
});
}
let after_parent = commit.parent_ms.is_none_or(|parent| write.ts_ms > parent);
after_parent.then_some(Evidence::Touched)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SessionSummary {
pub host: String,
pub session: String,
pub files: usize,
pub lines: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub untrusted: Option<Untrusted>,
pub commits: usize,
}
#[must_use]
pub fn summarise(notes: &[Note]) -> Vec<SessionSummary> {
type Acc<'a> = (
std::collections::BTreeSet<&'a str>,
u64,
Option<(i64, &'a Untrusted)>,
std::collections::BTreeSet<&'a str>,
);
let mut by: BTreeMap<(&str, &str), Acc<'_>> = BTreeMap::new();
for note in notes {
for file in ¬e.files {
let acc = by
.entry((file.host.as_str(), file.session.as_str()))
.or_default();
acc.0.insert(file.path.as_str());
acc.1 = acc.1.saturating_add(file.line_count());
if let Some(untrusted) = &file.untrusted
&& acc.2.is_none_or(|(event, _)| file.event < event)
{
acc.2 = Some((file.event, untrusted));
}
acc.3.insert(note.commit.as_str());
}
}
by.into_iter()
.map(
|((host, session), (files, lines, untrusted, commits))| SessionSummary {
host: host.to_owned(),
session: session.to_owned(),
files: files.len(),
lines,
untrusted: untrusted.map(|(_, u)| u.clone()),
commits: commits.len(),
},
)
.collect()
}
#[cfg(test)]
mod tests;