use std::path::{Path, PathBuf};
use std::process::ExitCode;
use fairlead_core::plan::Status;
use fairlead_tests::git;
use serde::Serialize;
use crate::brief_cmd::{self, Brief, Store};
use crate::knowledge::lesson;
use crate::receipt_cmd::{self, Gate};
const SHOWN: usize = 5;
const NO_BRIEF: &str = "resume: no brief on this branch yet; `fairlead brief <paths>` lists what a change reaches and starts one";
#[derive(clap::Args)]
pub struct ResumeArgs {
#[arg(long)]
session: Option<String>,
#[arg(long, conflicts_with = "hook")]
json: bool,
#[arg(long)]
hook: bool,
}
#[derive(Serialize)]
pub struct Resume {
pub brief: String,
pub found_by: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub session: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub base: Option<String>,
pub updated_at: String,
pub paths: Vec<String>,
pub in_brief: Vec<String>,
pub outside: Vec<String>,
pub gate: Gate,
pub next: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub ci: Option<String>,
pub lessons: Vec<Added>,
}
#[derive(Serialize)]
pub struct Added {
pub id: String,
pub title: String,
}
pub fn run(args: ResumeArgs, cwd: &Path) -> ExitCode {
if args.hook {
return hook();
}
let session = args
.session
.clone()
.or_else(|| std::env::var(brief_cmd::SESSION_ENV).ok());
match resume(cwd, session.as_deref()) {
Ok(Some(r)) if args.json => {
println!(
"{}",
serde_json::to_string_pretty(&r).expect("resume serializes")
);
ExitCode::SUCCESS
}
Ok(Some(r)) => {
print!("{}", text(&r));
ExitCode::SUCCESS
}
Ok(None) if args.json => {
println!("{}", serde_json::json!({ "brief": null }));
ExitCode::SUCCESS
}
Ok(None) => {
println!("{NO_BRIEF}");
ExitCode::SUCCESS
}
Err(e) => {
eprintln!("resume: {e}");
ExitCode::from(2)
}
}
}
fn hook() -> ExitCode {
let mut input = String::new();
let _ = std::io::Read::read_to_string(&mut std::io::stdin(), &mut input);
let dir = serde_json::from_str::<serde_json::Value>(&input)
.ok()
.and_then(|call| call["cwd"].as_str().map(PathBuf::from))
.or_else(|| std::env::current_dir().ok());
let setup = dir.and_then(|d| crate::setup_items::session_line(&d));
let context = match (hook_context(&input), setup) {
(Some(resume), Some(setup)) => Some(format!("{resume}\n{setup}")),
(resume, setup) => resume.or(setup),
};
if let Some(context) = context {
let answer = serde_json::json!({
"hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": context }
});
println!("{answer}");
}
ExitCode::SUCCESS
}
fn hook_context(input: &str) -> Option<String> {
let call: serde_json::Value = serde_json::from_str(input).unwrap_or_default();
let dir = call["cwd"]
.as_str()
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())?;
let r = resume(&dir, call["session_id"].as_str()).ok()??;
Some(format!(
"fairlead: where the last session on this branch stopped.\n{}",
text(&r)
))
}
fn find(root: &Path, session: Option<&str>) -> Option<(Brief, &'static str)> {
let store = Store::open(root)?;
if let Some(b) = session.and_then(|s| store.current(Some(s))) {
return Some((b, "session"));
}
let base = brief_cmd::base(root, None);
store
.newest_on(git::branch(root).as_deref(), base.as_deref())
.map(|b| (b, "branch"))
}
pub fn resume(cwd: &Path, session: Option<&str>) -> Result<Option<Resume>, String> {
let root = crate::graph_cmd::repo_root(cwd);
let Some((brief, found_by)) = find(&root, session) else {
return Ok(None);
};
let s = receipt_cmd::state_for(cwd, Some(brief.clone()), brief.base.clone())?;
let (in_brief, outside): (Vec<String>, Vec<String>) = s
.changed
.iter()
.cloned()
.partition(|p| brief.paths.contains(p));
let dir = format!("{}/", s.now.config.memory.dir.trim_end_matches('/'));
let (known, _) = lesson::load(&root, &s.now.config.memory);
let lessons = s
.now
.plan
.changed
.iter()
.filter(|c| c.status == Status::Added && c.path.starts_with(&dir))
.filter_map(|c| {
let id = Path::new(&c.path).file_stem()?.to_str()?.to_string();
let title = known
.iter()
.find(|l| l.front.id == id)
.map_or(String::new(), |l| l.front.title.clone());
Some(Added { id, title })
})
.collect();
Ok(Some(Resume {
ci: crate::ledger_text::head_line(&root),
next: receipt_cmd::next_line(&s),
gate: s.gate,
brief: brief.id,
found_by,
session: brief.session,
branch: brief.branch,
base: brief.base,
updated_at: brief.updated_at,
paths: brief.paths,
in_brief,
outside,
lessons,
}))
}
fn names(items: &[String]) -> String {
let shown: Vec<&str> = items.iter().take(SHOWN).map(String::as_str).collect();
let more = items.len().saturating_sub(SHOWN);
if more > 0 {
format!("{} … {more} more", shown.join(", "))
} else {
shown.join(", ")
}
}
pub fn text(r: &Resume) -> String {
let base = r
.base
.as_deref()
.map_or("none".into(), |c| c.chars().take(7).collect::<String>());
let whose = match r.found_by {
"session" => "this session's",
_ => "the newest on this branch",
};
let at = r
.updated_at
.get(..16)
.unwrap_or(&r.updated_at)
.replace('T', " ");
let mut out = format!(
"resume brief {} base {base} updated {at} ({whose})\n",
r.brief
);
out.push_str(&format!(" paths {}\n", names(&r.paths)));
let total = r.in_brief.len() + r.outside.len();
out.push_str(&format!(
"changed {total} file{} since its base: {} in the brief, {} outside\n",
if total == 1 { "" } else { "s" },
r.in_brief.len(),
r.outside.len()
));
if !r.outside.is_empty() {
out.push_str(&format!(" outside {}\n", names(&r.outside)));
}
out.push_str(&format!("gate {}\n", receipt_cmd::gate_line(&r.gate)));
if let Some(ci) = &r.ci {
out.push_str(&format!("ci {ci}\n"));
}
match r.lessons.len() {
0 => out.push_str("lessons none added on this branch\n"),
n => {
out.push_str(&format!(
"lessons {n} added on this branch (fairlead lessons list)\n"
));
for l in r.lessons.iter().take(SHOWN) {
out.push_str(&format!(" {:<40} {}\n", l.id, l.title));
}
if n > SHOWN {
out.push_str(&format!(" … {} more\n", n - SHOWN));
}
}
}
out.push_str(&r.next.replacen("next: ", "next ", 1));
out.push('\n');
out
}