use std::fmt::{self, Display};
use std::fs::{self, File, OpenOptions};
use std::io::{Read, Seek, SeekFrom, Write};
use std::path::{Path, PathBuf};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result};
use chrono::Local;
use crate::command_log;
use crate::issue::Repo;
use crate::progress;
const FILE: &str = "activity.log";
const TIME_FORMAT: &str = "%Y-%m-%d %H:%M:%S";
const TIME_FORMAT_LENGTH: usize = "2026-10-03 10:41:01".len();
const TAIL: u64 = 64 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Run,
SpecRun,
ArchitectRun,
PickupRun,
}
impl Display for Kind {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str(match self {
Kind::Run => "Run",
Kind::SpecRun => "Spec run",
Kind::ArchitectRun => "Architect run",
Kind::PickupRun => "Pickup run",
})
}
}
struct Started {
root: PathBuf,
kind: Kind,
issue: Option<u64>,
}
static STARTED: OnceLock<Started> = OnceLock::new();
static WARNED: AtomicBool = AtomicBool::new(false);
pub fn start(logs_dir: &Path, repo: &Repo, kind: Kind, issue: Option<u64>) {
let root = &command_log::root(logs_dir, repo);
let command_log = match command_log::path() {
Some(path) => path
.strip_prefix(root)
.unwrap_or(&path)
.display()
.to_string(),
None => "no Command log".to_string(),
};
let started = Started {
root: root.to_path_buf(),
kind,
issue,
};
let line = format!("{} started: {command_log}", started.subject());
if STARTED.set(started).is_ok() {
write(root, &line, None);
}
}
pub fn end(outcome: impl Display) {
if let Some(started) = STARTED.get() {
let line = format!("{} ended: {outcome}", started.subject());
write(&started.root, &line, None);
}
}
pub fn skip(logs_dir: &Path, repo: &Repo, kind: Kind, reason: impl Display) {
let root = command_log::root(logs_dir, repo);
write(&root, &format!("{kind} skipped: {reason}"), Some(kind));
}
impl Started {
fn subject(&self) -> String {
match self.issue {
Some(number) => format!("{} #{number}", self.kind),
None => self.kind.to_string(),
}
}
}
fn write(root: &Path, line: &str, unless_last_of: Option<Kind>) {
let appended = append(&root.join(FILE), line, unless_last_of);
if let Err(error) = appended
&& !WARNED.swap(true, Ordering::Relaxed)
{
command_log::show_held();
progress::step(format_args!(
"warning: could not keep the Activity log: {error:#}"
));
}
}
fn append(path: &Path, line: &str, unless_last_of: Option<Kind>) -> Result<()> {
if let Some(dir) = path.parent() {
fs::create_dir_all(dir).with_context(|| format!("could not create {}", dir.display()))?;
}
let mut file = OpenOptions::new()
.read(true)
.append(true)
.create(true)
.open(path)
.with_context(|| format!("could not open {}", path.display()))?;
if let Some(kind) = unless_last_of {
let tail = tail(&mut file).with_context(|| format!("could not read {}", path.display()))?;
if last_of(&tail, kind) == Some(line) {
return Ok(());
}
}
let stamped = format!("{} {line}\n", Local::now().format(TIME_FORMAT));
file.write_all(stamped.as_bytes())
.with_context(|| format!("could not write {}", path.display()))
}
fn tail(file: &mut File) -> std::io::Result<String> {
let length = file.metadata()?.len();
let from = length.saturating_sub(TAIL);
file.seek(SeekFrom::Start(from))?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes)?;
let text = String::from_utf8_lossy(&bytes).into_owned();
Ok(match (from, text.split_once('\n')) {
(1.., Some((_, rest))) => rest.to_string(),
_ => text,
})
}
fn last_of(tail: &str, kind: Kind) -> Option<&str> {
let kind = kind.to_string();
tail.lines().rev().find_map(|line| {
let unstamped = line.get(TIME_FORMAT_LENGTH + 1..)?;
let rest = unstamped.strip_prefix(&kind)?;
(rest.starts_with(' ') || rest.starts_with(':')).then_some(unstamped)
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_kinds_last_line_is_found_past_other_kinds_without_its_time() {
let tail = "\
2026-10-03 10:00:00 Pickup run skipped: no Ready issue on acme/widgets
2026-10-03 10:00:01 Architect run skipped: a plan is open
2026-10-03 10:01:00 Run #7 started: commands/issue/7-x.log
";
assert_eq!(
last_of(tail, Kind::PickupRun),
Some("Pickup run skipped: no Ready issue on acme/widgets")
);
assert_eq!(
last_of(tail, Kind::ArchitectRun),
Some("Architect run skipped: a plan is open")
);
assert_eq!(
last_of(tail, Kind::Run),
Some("Run #7 started: commands/issue/7-x.log")
);
assert_eq!(last_of(tail, Kind::SpecRun), None);
}
#[test]
fn a_line_cut_short_or_with_no_time_is_never_a_kinds_last_line() {
assert_eq!(last_of("Pickup run skipped\n", Kind::PickupRun), None);
assert_eq!(last_of("", Kind::PickupRun), None);
}
}