use std::collections::{BTreeMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use anyhow::{Context, Result};
use chrono::{DateTime, FixedOffset, NaiveDateTime, NaiveTime};
use serde::Serialize;
use crate::logs::activity::TIME_FORMAT;
use crate::logs::command_record::Issue;
use crate::logs::owner;
use crate::progress::ChildLine;
#[derive(Clone, Serialize)]
pub(in crate::logs) struct Command {
pub repository: String,
pub(in crate::logs) kind: String,
pub(in crate::logs) issue: Option<Issue>,
harness: Option<String>,
model: Option<String>,
effort: Option<String>,
pub started_at: String,
pub ended_at: Option<String>,
pub(in crate::logs) outcome: Option<String>,
pub(in crate::logs) command_log: Option<PathBuf>,
pub(in crate::logs) session_logs: Vec<PathBuf>,
stages: [(); 0],
stage: Option<()>,
source: &'static str,
pub state: &'static str,
}
impl Command {
fn from_logs(
repository: &str,
kind: &str,
number: Option<u64>,
started: DateTime<FixedOffset>,
source: &'static str,
) -> Self {
Self {
repository: repository.into(),
kind: kind.into(),
issue: number.map(|number| Issue {
number,
url: format!("https://github.com/{repository}/issues/{number}"),
title: None,
}),
harness: None,
model: None,
effort: None,
started_at: started.to_rfc3339(),
ended_at: None,
outcome: None,
command_log: None,
session_logs: Vec::new(),
stages: [],
stage: None,
source,
state: "unknown",
}
}
}
struct Started {
subject: String,
offset: FixedOffset,
log: String,
command: Command,
}
impl Started {
fn ending_distance(&self, time: NaiveDateTime, summary: &str) -> Option<i64> {
let ending = summary
.strip_prefix("plan ")
.and_then(|summary| summary.split_once(" dispatched: "))
.map_or(summary, |(_, ending)| ending);
let ending = ending
.strip_prefix("failed: ")
.or_else(|| ending.strip_prefix("review required: "))
.unwrap_or(ending);
self.log
.lines()
.filter_map(|line| {
let ChildLine::Own(message) = ChildLine::of(line) else {
return None;
};
if message.is_empty()
|| !(summary == message
|| ending == message
|| ending
.strip_prefix(message)
.is_some_and(|suffix| suffix.starts_with("; ")))
{
return None;
}
let clock = line
.strip_prefix("thirdshift: ")
.and_then(|line| line.split_once(' '))
.and_then(|(clock, _)| NaiveTime::parse_from_str(clock, "%H:%M:%S").ok());
Some(clock.map_or(i64::MAX, |clock| {
clock
.signed_duration_since(time.time())
.num_seconds()
.rem_euclid(24 * 3600)
}))
})
.min()
}
}
pub(in crate::logs) fn read(repo: &Path, covered: &HashSet<PathBuf>) -> Result<Vec<Command>> {
let repository = format!(
"{}/{}",
repo.parent()
.and_then(Path::file_name)
.unwrap_or_default()
.to_string_lossy(),
repo.file_name().unwrap_or_default().to_string_lossy()
);
let mut starts: Vec<Started> = Vec::new();
for line in read_optional(&repo.join("activity.log"))?.lines() {
let Some((time, text)) = line.split_once(' ').and_then(|(date, rest)| {
let (clock, text) = rest.split_once(' ')?;
Some((
NaiveDateTime::parse_from_str(&format!("{date} {clock}"), TIME_FORMAT).ok()?,
text,
))
}) else {
continue;
};
if let Some((subject, detail)) = text.split_once(" started: ") {
if let Some(started) = start(repo, &repository, subject, detail, time)? {
starts.push(started);
}
} else if let Some((subject, outcome)) = text.split_once(" ended: ") {
let candidates: Vec<_> = starts
.iter()
.enumerate()
.filter(|(_, start)| start.subject == subject && start.command.ended_at.is_none())
.map(|(index, _)| index)
.collect();
let matches: Vec<_> = candidates
.iter()
.filter_map(|&index| {
starts[index]
.ending_distance(time, outcome)
.map(|distance| (index, distance))
})
.collect();
let matched = matches
.iter()
.min_by_key(|(_, distance)| distance)
.filter(|(_, distance)| {
matches
.iter()
.filter(|(_, other)| other == distance)
.count()
== 1
})
.map(|&(index, _)| index);
let index = matched.or_else(|| (candidates.len() == 1).then(|| candidates[0]));
if let Some(index) = index {
let start = &mut starts[index];
start.command.ended_at = time
.and_local_timezone(start.offset)
.single()
.map(|time| time.to_rfc3339());
start.command.outcome = Some(outcome.into());
start.command.state = "ended";
}
}
}
let recorded = covered;
let mut covered = recorded.clone();
let mut shared_owners = Vec::new();
for start in &starts {
if let Some(path) = &start.command.command_log {
covered.insert(path.clone());
if start.command.kind != "Run" && owner::decode(path).is_some() {
shared_owners.push(path.clone());
}
}
}
let mut commands: Vec<_> = starts
.into_iter()
.map(|start| start.command)
.filter(|command| {
command
.command_log
.as_ref()
.is_none_or(|path| !recorded.contains(path))
})
.collect();
commands.extend(sessions(repo, &repository, &covered, &shared_owners)?);
Ok(commands)
}
fn start(
repo: &Path,
repository: &str,
subject: &str,
detail: &str,
time: NaiveDateTime,
) -> Result<Option<Started>> {
let (kind, number) = match subject.rsplit_once(" #") {
Some((kind, number)) => (kind, number.parse::<u64>().ok()),
None => (subject, None),
};
if !matches!(
kind,
"Run" | "Spec run" | "Pickup run" | "Architect run" | "Security run"
) {
return Ok(None);
}
let (path, choice) = detail.rsplit_once(", on ").unwrap_or((detail, ""));
let relative = Path::new(path);
if !relative.starts_with("commands")
|| relative
.components()
.any(|part| !matches!(part, Component::Normal(_)))
{
return Ok(None);
}
let Some((_, stamp)) = super::command_stamp(relative) else {
return Ok(None);
};
let path = repo.join(relative);
let mut choice = choice.split(" · ");
let harness = choice
.next()
.filter(|name| !name.is_empty())
.map(String::from);
let model = choice
.next()
.filter(|value| *value != "default model")
.map(String::from);
let effort = choice
.next()
.filter(|value| *value != "default effort")
.map(String::from);
let mut command = Command::from_logs(
repository,
kind,
number,
time.and_local_timezone(*stamp.offset()).single().unwrap(),
"activity_log",
);
command.harness = harness;
command.model = model;
command.effort = effort;
command.command_log = Some(path.clone());
Ok(Some(Started {
subject: subject.into(),
offset: *stamp.offset(),
log: read_optional(&path)?,
command,
}))
}
fn sessions(
repo: &Path,
repository: &str,
covered: &HashSet<PathBuf>,
shared_owners: &[PathBuf],
) -> Result<Vec<Command>> {
let mut groups: BTreeMap<PathBuf, Command> = BTreeMap::new();
for folder in super::directories(&repo.join("commands"))? {
for entry in super::entries(&folder)? {
if !entry.file_type()?.is_file() {
continue;
}
let path = entry.path();
let Some(decoded) =
owner::decode(&path).filter(|decoded| decoded.session_kind.is_some())
else {
continue;
};
let prefix = decoded.subject;
let stamp = decoded.time();
let (kind, number) = match prefix.parse::<u64>() {
Ok(number) => ("Run", Some(number)),
Err(_) if prefix == "architect" => ("Architect run", None),
Err(_) if prefix == "secure" => ("Security run", None),
Err(_) => continue,
};
let command_log =
owner::evidenced_log(&path).unwrap_or_else(|| decoded.command_log(&path));
if covered.contains(&path)
|| covered.contains(&command_log)
|| shared_owners.iter().any(|log| {
log.parent() == Some(folder.as_path()) && owner::session_matches(&path, log)
})
{
continue;
}
let command = groups.entry(command_log.clone()).or_insert_with(|| {
let mut command =
Command::from_logs(repository, kind, number, stamp, "session_log");
command.command_log = command_log.is_file().then_some(command_log);
command
});
command.session_logs.push(entry.path());
}
}
for command in groups.values_mut() {
command.session_logs.sort();
}
Ok(groups.into_values().collect())
}
fn read_optional(path: &Path) -> Result<String> {
match fs::read_to_string(path) {
Ok(text) => Ok(text),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(String::new()),
Err(error) => Err(error).with_context(|| format!("could not read {}", path.display())),
}
}