use std::path::Path;
use std::process::ExitCode;
use fairlead_tests::git;
use serde::Serialize;
use crate::brief_cmd::{self, Brief};
use crate::knowledge::{lesson, secrets, skill};
const CAP: usize = 120;
const DOCS: usize = 3;
const DOC_LINES: usize = 12;
const COMMITS: usize = 3;
#[derive(clap::Args)]
pub struct ContextArgs {
#[arg(required = true, num_args = 1..)]
paths: Vec<String>,
#[arg(long)]
base: Option<String>,
#[arg(long)]
session: Option<String>,
#[arg(long)]
fresh: bool,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
#[arg(long = "set", value_name = "KEY=VALUE")]
sets: Vec<String>,
}
#[derive(Serialize)]
pub struct Context {
pub brief: Brief,
pub lessons: Vec<LessonBody>,
pub skills: Vec<SkillRef>,
pub docs: Vec<DocSection>,
pub history: Vec<History>,
}
#[derive(Serialize)]
pub struct LessonBody {
pub id: String,
pub why: String,
pub path: String,
pub body: String,
}
#[derive(Serialize)]
pub struct SkillRef {
pub name: String,
pub description: String,
pub path: String,
}
#[derive(Serialize)]
pub struct DocSection {
pub path: String,
pub heading: String,
pub lines: Vec<String>,
}
#[derive(Serialize)]
pub struct History {
pub path: String,
pub commits: Vec<String>,
}
pub fn run(args: ContextArgs, cwd: &Path) -> ExitCode {
match context(&args, cwd) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
eprintln!("context: {e}");
ExitCode::from(2)
}
}
}
fn context(args: &ContextArgs, cwd: &Path) -> Result<(), String> {
let (brief, planned) = brief_cmd::build(
cwd,
&args.paths,
args.base.as_deref(),
args.session.clone(),
&args.sets,
args.fresh,
)?;
let root = crate::graph_cmd::repo_root(cwd);
let root = std::fs::canonicalize(&root).unwrap_or(root);
let (lessons, _) = lesson::load(&root, &planned.config.memory);
let lessons = brief
.lessons
.items
.iter()
.filter_map(|item| {
let l = lessons.iter().find(|l| l.front.id == item.name)?;
Some(LessonBody {
id: item.name.clone(),
why: item.why.clone(),
path: l.path.clone(),
body: l.body.clone(),
})
})
.collect();
let (skills, _) = skill::load(&root, &planned.config.skills);
let skills = brief
.skills
.items
.iter()
.filter_map(|item| {
let s = skills.iter().find(|s| s.name == item.name)?;
Some(SkillRef {
name: s.name.clone(),
description: s.description.clone(),
path: s.path.clone(),
})
})
.collect();
let docs = docs(&root, &brief.paths);
let mut ctx = Context {
brief,
lessons,
skills,
docs,
history: Vec::new(),
};
let existing: Vec<String> = ctx
.brief
.paths
.iter()
.filter(|p| root.join(p).is_file())
.cloned()
.collect();
if args.json {
ctx.history = existing.iter().map(|p| history(&root, p)).collect();
println!(
"{}",
serde_json::to_string_pretty(&ctx).expect("context serializes")
);
} else {
let cap = (!args.all).then_some(CAP);
print!("{}", text(&ctx, cap, &existing, &|p| history(&root, p)));
}
Ok(())
}
fn history(root: &Path, path: &str) -> History {
History {
path: path.to_string(),
commits: git::recent_commits(root, path, COMMITS)
.into_iter()
.filter(|c| subject_is_clean(c))
.collect(),
}
}
fn subject_is_clean(line: &str) -> bool {
secrets::scan(line.splitn(3, ' ').nth(2).unwrap_or("")).is_empty()
}
fn docs(root: &Path, paths: &[String]) -> Vec<DocSection> {
let mut seen: Vec<String> = Vec::new();
for p in paths {
let mut dir = Path::new(p).parent();
while let Some(d) = dir {
let readme = if d.as_os_str().is_empty() {
"README.md".to_string()
} else {
format!("{}/README.md", d.display())
};
if root.join(&readme).is_file() {
if !seen.contains(&readme) {
seen.push(readme);
}
break;
}
dir = d.parent();
}
if seen.len() == DOCS {
break;
}
}
seen.into_iter()
.filter_map(|path| {
let text = std::fs::read_to_string(root.join(&path)).ok()?;
let (heading, lines) = first_section(&text)?;
let lines = match secrets::scan(&lines.join("\n")).first() {
Some(found) => vec![format!("left out: it looks like it holds {}", found.kind)],
None => lines,
};
Some(DocSection {
path,
heading,
lines,
})
})
.collect()
}
fn first_section(text: &str) -> Option<(String, Vec<String>)> {
let mut lines = text.lines().skip_while(|l| l.trim().is_empty()).peekable();
let first = *lines.peek()?;
let heading = if first.starts_with('#') {
lines.next();
first.trim_start_matches('#').trim().to_string()
} else {
String::new()
};
let mut fenced = false;
let mut body: Vec<String> = Vec::new();
for line in lines {
if line.trim_start().starts_with("```") {
fenced = !fenced;
}
if !fenced && line.starts_with('#') {
break;
}
body.push(line.to_string());
}
while body.first().is_some_and(|l| l.trim().is_empty()) {
body.remove(0);
}
while body.last().is_some_and(|l| l.trim().is_empty()) {
body.pop();
}
(!heading.is_empty() || !body.is_empty()).then_some((heading, body))
}
struct Capped {
lines: Vec<String>,
cap: Option<usize>,
left_out: usize,
}
impl Capped {
fn full(&self) -> bool {
self.left_out > 0
}
fn block(&mut self, block: Vec<String>) {
let room = self.cap.map_or(usize::MAX, |c| c - 1);
if self.full() || self.lines.len() + block.len() > room {
self.left_out += 1;
} else {
self.lines.extend(block);
}
}
fn finish(mut self) -> String {
if self.full() {
self.lines
.push(format!("… {} more (fairlead context --all)", self.left_out));
}
self.lines.into_iter().map(|l| l + "\n").collect()
}
}
pub fn text(
ctx: &Context,
cap: Option<usize>,
existing: &[String],
history: &dyn Fn(&str) -> History,
) -> String {
let all = cap.is_none();
let brief: Vec<String> = brief_cmd::text(&ctx.brief, all)
.lines()
.map(str::to_string)
.collect();
let room = cap.map_or(brief.len(), |c| brief.len().min(c - 1));
let mut out = Capped {
lines: brief[..room].to_vec(),
cap,
left_out: usize::from(room < brief.len()),
};
for l in &ctx.lessons {
let mut block = vec![format!("lesson {} {}", l.id, l.why)];
block.extend(l.body.lines().map(|b| format!(" {b}")));
out.block(block);
}
for s in &ctx.skills {
let about = if s.description.is_empty() {
String::new()
} else {
format!(" {}", s.description)
};
out.block(vec![
format!("skill {}{about}", s.name),
format!(" load it before editing: {}", s.path),
]);
}
for d in &ctx.docs {
let mut block = vec![format!("docs {} {}", d.path, d.heading)];
let shown = if all {
d.lines.len()
} else {
d.lines.len().min(DOC_LINES)
};
block.extend(d.lines[..shown].iter().map(|l| format!(" {l}")));
if shown < d.lines.len() {
block.push(format!(" … the rest is in {}", d.path));
}
out.block(block);
}
for p in existing {
if out.full() {
out.left_out += 1;
continue;
}
let h = history(p);
if h.commits.is_empty() {
continue;
}
let mut block = vec![format!("history {}", h.path)];
block.extend(h.commits.iter().map(|c| format!(" {c}")));
out.block(block);
}
out.finish()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_first_section_is_the_first_heading_and_what_it_holds() {
let text = "\n# Forms\n\nHow forms validate.\n```\n# not a heading\n```\n\n## Next\nmore\n";
let (heading, lines) = first_section(text).unwrap();
assert_eq!(heading, "Forms");
assert_eq!(
lines,
["How forms validate.", "```", "# not a heading", "```"]
);
let (heading, lines) = first_section("Just text.\n# Later\n").unwrap();
assert_eq!((heading.as_str(), lines.len()), ("", 1));
assert!(first_section("\n\n").is_none());
}
#[test]
fn a_commit_is_left_out_for_its_subject_never_for_its_hash_and_date() {
assert!(subject_is_clean("6807822 2026-10-10 init"));
assert!(!subject_is_clean(
"abc1234 2026-10-10 pay with 4111 1111 1111 1111"
));
}
#[test]
fn a_block_that_does_not_fit_is_counted_with_every_later_one() {
let mut out = Capped {
lines: vec!["brief".into()],
cap: Some(4),
left_out: 0,
};
out.block(vec!["a".into(), "b".into()]);
out.block(vec!["c".into(), "d".into()]);
out.block(vec!["e".into()]);
assert_eq!(
out.finish(),
"brief\na\nb\n… 2 more (fairlead context --all)\n"
);
}
}