use std::{
path::{Path, PathBuf},
sync::mpsc::{Receiver, channel},
};
use anyhow::{Context, Result, anyhow, bail};
use crate::{
ask,
config::{AskConfig, TraceConfig},
deck::{Deck, is_url},
scheduler::{Grade, SchedulerKind},
store::Store,
};
const MAX_EXCERPT_LINES: usize = 60;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Delta {
Got,
Partial,
Missed,
}
impl Delta {
pub fn from_key(c: char) -> Option<Delta> {
match c.to_ascii_lowercase() {
'g' => Some(Delta::Got),
'p' => Some(Delta::Partial),
'm' => Some(Delta::Missed),
_ => None,
}
}
pub fn label(self) -> &'static str {
match self {
Delta::Got => "GOT IT",
Delta::Partial => "PARTIAL",
Delta::Missed => "MISSED",
}
}
pub fn grade(self) -> Grade {
match self {
Delta::Got => Grade::Pass,
Delta::Partial | Delta::Missed => Grade::Fail,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Checkpoint {
pub prompt: String,
pub points: Vec<String>,
pub givens: Vec<String>,
pub note: Option<String>,
pub locator: Option<String>,
pub card_id: u64,
}
#[derive(Clone, Debug)]
pub struct Trace {
pub description: String,
pub source: Option<String>,
pub checkpoints: Vec<Checkpoint>,
base_dir: PathBuf,
source_file: Option<PathBuf>,
}
impl Trace {
pub fn from_deck(deck: &Deck) -> Result<Trace> {
let description = deck
.trace
.clone()
.ok_or_else(|| anyhow!("{} is not a trace: it declares no `% trace:`", deck.subject))?;
if deck.cards.is_empty() {
bail!("the trace `{}` has no checkpoints", deck.subject);
}
let checkpoints = deck
.cards
.iter()
.map(|c| Checkpoint {
prompt: c.front.clone(),
points: c.back.clone(),
givens: c.givens.clone(),
note: c.note.clone(),
locator: c.at.clone(),
card_id: c.id(),
})
.collect();
let source = deck.sources.first().cloned();
let (base_dir, source_file) = resolve_source(deck.path.parent(), source.as_deref());
Ok(Trace {
description,
source,
checkpoints,
base_dir,
source_file,
})
}
pub fn excerpt(&self, checkpoint: &Checkpoint) -> Result<Excerpt> {
let locator = checkpoint
.locator
.as_deref()
.ok_or_else(|| anyhow!("this checkpoint has no `% at:` locator to reveal"))?;
excerpt_at(&self.base_dir, self.source_file.as_deref(), locator)
}
pub fn lint_locators(&self) -> Vec<LocatorIssue> {
let mut issues = Vec::new();
let url_source = self.source.as_deref().is_some_and(is_url);
for (i, cp) in self.checkpoints.iter().enumerate() {
let Some(locator) = cp.locator.as_deref() else {
issues.push(LocatorIssue {
checkpoint: i,
message: "no `% at:` locator — a walk can't reveal its source".to_string(),
});
continue;
};
if url_source {
continue; }
let (file, spec) = parse_locator(locator);
let path = match file {
Some(f) => self.base_dir.join(f),
None => match &self.source_file {
Some(p) => p.clone(),
None => {
issues.push(LocatorIssue {
checkpoint: i,
message: format!(
"locator `{locator}` gives only line numbers, but `% source:` \
is not a single file — write it as `file:lines`"
),
});
continue;
}
},
};
let Ok(text) = std::fs::read_to_string(&path) else {
issues.push(LocatorIssue {
checkpoint: i,
message: format!(
"locator `{locator}` → `{}`: file not found or unreadable",
path.display()
),
});
continue;
};
let Some(spec) = spec else { continue }; let (start, end) = parse_line_range(&spec);
let n = text.lines().count();
if start > n {
issues.push(LocatorIssue {
checkpoint: i,
message: format!(
"locator `{locator}` starts at line {start}, but `{}` has only {n} \
lines — the source changed; re-point it",
path.display()
),
});
} else if end > n {
issues.push(LocatorIssue {
checkpoint: i,
message: format!(
"locator `{locator}` ends at line {end}, but `{}` has only {n} lines \
— the excerpt is clamped short; re-point it",
path.display()
),
});
}
}
issues
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct LocatorIssue {
pub checkpoint: usize,
pub message: String,
}
pub fn build(deck: &Deck, cfg: &TraceConfig, ask_cfg: &AskConfig) -> Result<String> {
let description = deck
.trace
.as_deref()
.ok_or_else(|| anyhow!("{} declares no `% trace:` to build", deck.subject))?;
let source = deck
.sources
.first()
.ok_or_else(|| anyhow!("{} declares no `% source:` scope to trace", deck.subject))?;
let url = is_url(source);
let cwd = if url {
None
} else {
let (base_dir, _) = resolve_source(deck.path.parent(), Some(source));
Some(base_dir)
};
let prompt = build_prompt(description, source, url, cfg);
let run_cfg = build_run_config(cfg, ask_cfg, cwd, url);
let raw = ask::run(&run_cfg, &prompt, &[])?;
let cards = clean_to_cards(&raw);
if cards.trim().is_empty() {
bail!("the build produced no checkpoints");
}
Ok(cards)
}
pub(crate) const SNAPSHOT_DIR: &str = "assets";
#[derive(Debug)]
pub(crate) struct SnapshotReport {
pub copied: Vec<String>,
pub missing: Vec<String>,
}
pub(crate) fn snapshot(deck: &Deck, start: usize) -> Result<SnapshotReport> {
let deck_dir = deck.path.parent().unwrap_or_else(|| Path::new("."));
if !crate::workspace::is_workspace(deck_dir) {
bail!(
"a deck snapshots into its workspace's `assets/`, but {} is not in a \
workspace (no `alix.toml`).",
deck.path.display()
);
}
let source = deck
.sources
.first()
.ok_or_else(|| anyhow!("{} declares no `% source:` to snapshot", deck.subject))?;
if is_url(source) {
bail!("`{source}` is a URL — there are no local excerpts to snapshot");
}
let (base_dir, source_file) = resolve_source(Some(deck_dir), Some(source));
let assets_dir = deck_dir.join(SNAPSHOT_DIR);
let mut copied = Vec::new();
let mut missing = Vec::new();
let mut ats: Vec<crate::deck::AtRewrite> = Vec::new();
for card in &deck.cards {
let Some(locator) = card.at.as_deref() else {
continue;
};
match excerpt_at(&base_dir, source_file.as_deref(), locator) {
Ok(excerpt) => {
let n = start + copied.len() + 1;
let ext = excerpt_ext(&excerpt);
let name = format!("{n:02}.{ext}");
write_snippet(&assets_dir.join(&name), &excerpt)?;
copied.push(name.clone());
ats.push(crate::deck::AtRewrite {
at: name,
note: excerpt_provenance(&excerpt),
});
}
Err(_) => {
missing.push(locator.to_string());
ats.push(crate::deck::AtRewrite {
at: locator.to_string(),
note: None,
});
}
}
}
if copied.is_empty() {
bail!(
"{} has no readable `% at:` excerpts to snapshot",
deck.subject
);
}
crate::deck::set_trace_snapshot(&deck.path, SNAPSHOT_DIR, &ats)?;
Ok(SnapshotReport { copied, missing })
}
fn excerpt_ext(excerpt: &Excerpt) -> String {
excerpt
.path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("txt")
.to_string()
}
fn excerpt_provenance(excerpt: &Excerpt) -> Option<String> {
let first = excerpt.lines.first()?.0;
let last = excerpt.lines.last()?.0;
if first <= 1 {
return None; }
let file = excerpt
.path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("source");
Some(if first == last {
format!("{file}:{first}")
} else {
format!("{file}:{first}-{last}")
})
}
fn write_snippet(dest: &Path, excerpt: &Excerpt) -> Result<()> {
if let Some(parent) = dest.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("cannot create {}", parent.display()))?;
}
let mut body: String = excerpt
.lines
.iter()
.map(|(_, line)| line.as_str())
.collect::<Vec<_>>()
.join("\n");
body.push('\n');
std::fs::write(dest, body).with_context(|| format!("cannot write {}", dest.display()))?;
Ok(())
}
pub fn suggest(source: &str, cfg: &TraceConfig, ask_cfg: &AskConfig) -> Result<String> {
let url = is_url(source);
let cwd = if url {
None
} else {
let (base_dir, _) = resolve_source(None, Some(source));
Some(base_dir)
};
let prompt = suggest_prompt(source, url, cfg);
let run_cfg = build_run_config(cfg, ask_cfg, cwd, url);
let raw = ask::run(&run_cfg, &prompt, &[])?;
let menu = raw.trim().to_string();
if menu.is_empty() {
bail!("the recon produced no suggestions");
}
Ok(menu)
}
pub(crate) fn build_run_config(
cfg: &TraceConfig,
ask_cfg: &AskConfig,
cwd: Option<PathBuf>,
url: bool,
) -> AskConfig {
let mut allowed_tools = vec!["Read".to_string(), "Glob".to_string(), "Grep".to_string()];
if url {
allowed_tools.push("WebFetch".to_string());
}
AskConfig {
command: ask_cfg.command.clone(),
permission_mode: ask_cfg.permission_mode.clone(),
allowed_tools,
model: cfg.model.clone().or_else(|| ask_cfg.model.clone()),
effort: cfg.effort.clone().or_else(|| ask_cfg.effort.clone()),
timeout_secs: cfg.timeout_secs,
cwd,
source_access: false,
}
}
fn build_prompt(description: &str, source: &str, url: bool, cfg: &TraceConfig) -> String {
let explore = if url {
format!("Read the source page at {source} with the WebFetch tool (fetch it once).")
} else {
"Your working directory is the source root. Explore it with the Read, Glob \
and Grep tools — start at the entry point or the most load-bearing file and \
follow the references. You can read any file under the source; you have no \
write or shell access."
.to_string()
};
let locator = if url {
"a short quoted span from the page — the exact sentence(s) the key points rest on"
} else {
"ONE contiguous range, `file:start-end` (or `file:N` for a single line) \
relative to the source root, e.g. `src/serve.rs:682-689` — NEVER \
comma-separated ranges"
};
let mut p = format!(
"You are tracing ONE path through a source so a learner can UNDERSTAND it by \
predicting each step before it is revealed. The path must answer:\n\n \
{description}\n\nSource (the scope): {source}\n{explore}\n\nFind the single \
load-bearing path from the trigger to the outcome named above — a real \
SEQUENCE (a data flow, a control flow, or a derivation), not a grab-bag of \
facts about the topic. Then write it as a series of CHECKPOINT cards, one \
per hop.\n\n\
FORMAT — output ONLY the checkpoint cards: no header, no `% trace:` or \
`% source:` line, no preamble, no code fences. Each checkpoint is:\n\n \
# <the question for this hop, asked plainly>\n \t% given: <name> — <what \
it is>\n \t<a key point a correct answer hits>\n \t<another key \
point>\n \t% at: <locator>\n \t! <one connecting insight, shown after \
the reveal>\n\n\
The `# ` front (column 0) is the QUESTION. The `% given:` lines (repeatable, \
optional) name off-screen symbols the question leans on — alix lists them \
under the question before the learner predicts. The indented lines under it \
are the key points the revealed source makes (the rubric). `% at:` is the \
locator: {locator} — it must point at the REAL lines/passage the key points \
paraphrase, because alix reads them live at review time as the ground \
truth. Cite accurately. The indented `! ` line is an optional note.\n\n\
SCOPE EACH HOP TO A SELF-CONTAINED UNIT, AND GLOSS WHAT YOU DON'T SHOW. The \
reader sees ONLY the lines you cite, so an excerpt must read on its own. \
Prefer hops that are a whole SMALL function/method — its inputs are its \
parameters, so nothing dangles. Do NOT dissect one big function into several \
checkpoints: a big function on the path is ONE black-box hop — cite its \
signature plus the load-bearing line(s) and describe what it does in the key \
points; if its internals are themselves worth understanding, that is a \
SEPARATE trace, not more hops here.\n\
The `% at:` is ONE CONTIGUOUS RANGE. NEVER stitch several ranges together \
(no commas): collapsing the gaps makes lines from different branches/places \
look adjacent, which misleads. If a hop cannot be shown in one contiguous \
span, it spans more than one region — that is the signal it is too big: \
split it, or black-box the function. \n\
GLOSS — completely and correctly. A `% given:` names a free variable: a \
symbol the cited span USES but does NOT BIND within those lines — typically \
a function PARAMETER (declared in the signature, above the body you cite) or \
a value from an enclosing/earlier scope. Apply a mechanical test to each \
symbol: if its binding (a `let`, an assignment, a `for x in`, the parameter \
itself) is INSIDE the cited lines, it is NOT a given — the reader sees it, \
do not gloss it; if the span uses it but its binding is OUTSIDE, it IS a \
given. (E.g. for a function body excerpt, the parameters are givens; a \
`let x = …` on a cited line is not.) Name each given with a `% given:` line, \
one per symbol, `name — what it is` (e.g. `% given: defaults — the workspace \
directive defaults, a parameter`). Check BOTH directions: every \
used-but-unbound symbol is a CANDIDATE given, and never gloss one the span \
binds itself. But gloss only what the reader can't DERIVE: a given earns \
its place when the symbol's meaning or origin is genuinely off-screen and \
not self-evident. A self-documenting field or parameter whose name already \
says what it is (`self.subject` on a `Card`) needs none — glossing the \
obvious just enumerates the answer's ingredients and shrinks the predict \
gap to nothing. The list MUST be COMPLETE in the honesty sense — no \
UNEXPLAINED dangling symbol — but that means what the span can't be read \
without, not glossing every name. alix shows them under the question, \
so NEVER cram them into the question text. The gloss names the inputs \
(scaffolding); the cited lines stay the ground truth for the predicted thing \
— never move the hop's answer into the gloss. More than ~3 givens means the \
hop is cut too fine: re-scope it.\n\
KEY POINTS MUST BE GROUNDED in the cited lines. Every key point has to be \
evident from the excerpt (or a given) — describe ONLY what those lines show, \
never the rest of the function or file. If a key point asserts behavior that \
is not in the cited lines (another branch, a later call, the return path), it \
does not belong to this hop: cite the lines that show it, or drop it and let \
another hop cover it. A whole-function \"what does it do?\" question whose \
honest answer needs code you did NOT cite is mis-scoped — either BLACK-BOX it \
(key points stay at the contract the signature/return actually shows) or \
SPLIT it into hops that each cite their own region. Before emitting a \
checkpoint, re-read its key points against ONLY its excerpt + givens and \
delete any claim you cannot point to.\n\n\
THE RULES THAT MAKE IT A PATH, NOT A QUIZ — follow every one:\n\
1. One path, not a set — and stay on the SPINE. Each hop is a step along \
one chain; if two checkpoints could be reordered without breaking, they \
are a set — re-trace the spine. Trace the path EVERY instance travels: a \
step that fires only for some inputs (a conditional branch, an optional \
transform like direction `both`/`reverse` that a plain forward card skips) \
is a side-branch, not a spine hop. Keep the main path on what all \
instances do; a branch worth understanding becomes a SEPARATE (nested) \
trace, not a detour most instances never take.\n\
2. Every question opens on the previous reveal: state the conclusion the \
prior hop established, then ask the next step. (Hop 1 has no prior.)\n\
3. Carry the STATE, not the bookkeeping: restate what is now true about the \
system (\"the request carries only the grade, no card id\"), NEVER \"as \
checkpoint 2 showed\" or \"the last hop\" — each checkpoint is reviewed \
alone, so an index reference is meaningless.\n\
4. Ask forward, and just ask: a plain question answerable by reasoning \
forward from the prior reveal. Do NOT prefix fronts with \"Predict\".\n\
5. Don't give the answer away: keep a hop's answer out of its own question. \
Avoid loaded tells (\"it lives ONLY in memory\" hands over \"so save it\"); \
state the setup neutrally and let the learner reason.\n\
6. Dives must return: if a hop calls into another function/file, the next \
hop may dive in, but then return to the caller before going past the call — \
bridge the return with state, reusing the call-site line so the seam shows.\n\
7. Each hop must TEACH — answer with the mechanism, not a deferral. The key \
points must say what ACTUALLY happens, never restate the question or hand off \
to a callee: \"it calls build_queue to produce the queue\" does NOT answer \
\"how is the order decided?\" — it just says another function decides. When \
the real work is inside a function the span merely CALLS, that callee is the \
hop: dive into it and ask the question THERE; do not frame the call-site's \
question as if it does the work. A thin delegating layer is folded into an \
honest handoff (\"the constructor hands queue-building to build_queue\") or \
skipped. There must be a real gap between a sensible guess and the reveal; if \
the answer is obvious or circular, cut or re-aim the hop.\n\
8. The last hop reaches the outcome the path was tracing toward.\n\n\
Keep each question one or two sentences and each key point one line. Use as \
many checkpoints as the path needs (typically 4-8); never pad."
);
if let Some(extra) = cfg
.extra
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
p.push_str("\n\nAdditional instructions:\n");
p.push_str(extra);
}
p
}
fn suggest_prompt(source: &str, url: bool, cfg: &TraceConfig) -> String {
let explore = if url {
format!("Read the source page at {source} with the WebFetch tool (fetch it once).")
} else {
"Your working directory is the source root. Explore it with the Read, Glob \
and Grep tools — read it the way you would cold: the manifest (what kind of \
thing + its stack), then the module names (the domain nouns), then the \
entry point, then the main path. You can read any file under the source; \
you have no write or shell access."
.to_string()
};
let scope = if url {
"the same URL"
} else {
"the whole source `.`, or a NARROWER scope (a subdirectory or single file) \
when a tight path lives in one place"
};
let mut p = format!(
"You are doing RECON on a source so a learner can decide WHAT to understand \
in it. Do NOT trace any path in depth and do NOT write checkpoints — that \
is a separate, expensive step the learner runs later on whichever \
suggestion they pick. Your one job is to SURVEY the scope in a single \
pass and propose a ranked MENU of the most central traces to START from — \
the entry points into understanding the source. This is deliberately the \
STARTING set (the central paths), NOT an exhaustive set that fully covers \
the source.\n\n\
Source (the scope): {source}\n{explore}\n\n\
A *trace* is a path-QUESTION — \"how X becomes Y\" — a real SEQUENCE from a \
trigger to an outcome (a data flow, a control flow, or a derivation), the \
kind of thread a learner predicts step by step. It is NOT a topic, a \
feature list, or a \"goal\" (a bigger, long-term aim that lives at the \
workspace level, like \"understand this crate\"); each suggestion must \
name a concrete path with two ends.\n\n\
COVERAGE, NOT A COUNT — this decides HOW MANY. Do not aim for a number. \
First identify the major subsystems of the source (its modules, domains, \
top-level parts). Then emit ONE candidate per major subsystem — its single \
most load-bearing path — plus the central spine that threads them \
together. STOP when every major subsystem is covered once: the list is \
exactly as long as that takes (a source with twelve subsystems yields \
about twelve; one with four yields about four). Do NOT pad to look \
thorough, and do NOT drop a real subsystem to look concise. EXCLUDE the \
local, leaf paths INSIDE a subsystem — those are deeper dives for a later \
step, not starting points. Each candidate must be sized to be ONE trace — \
a single spine, not \"understand this whole module\"; if a path is large, \
narrow its scope rather than widening the question. RANK by centrality: \
the spine first, then each subsystem's main path.\n\n\
EDGES vs NODES — a trace drills *edges* (a path predicted hop by hop); some \
subsystems are mostly *nodes*: a table of facts with no path to predict (a \
config's knobs, a store's on-disk format). Do NOT force those into traces — \
that manufactures a fake path; they are better learned as plain facts decks. \
But make the skip VISIBLE: after the candidates, list the node-shaped \
subsystems you deliberately left out, one line each with why. Skip trivial \
utilities silently; only call out real subsystems a learner might expect to \
see.\n\n\
FORMAT — output ONLY the menu, no preamble, no code fences. Start with two \
heading lines, then the numbered candidates:\n\n\
Source <one line: what this source is>\n\
Spine <the single most central path, as arrow-joined nouns>\n\n\
1. <the path-question, e.g. how a keypress becomes a saved grade>\n \
spine: <3–6 rough hop labels joined by arrows — NOT cited checkpoints>\n \
% source: <{scope}>\n\
2. …\n\n\
Skipped (node-shaped — facts-deck material, not traces):\n \
- <subsystem> — <why it's facts, not a path>\n \
- …\n\n\
The `spine:` is a rough sketch (hop labels only) so the learner can judge \
the trace at a glance and predict it; do not resolve line numbers, cite \
excerpts, or write key points — that is what `--build` does next. Keep \
each path-question one line."
);
if let Some(extra) = cfg
.extra
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
p.push_str("\n\nAdditional instructions:\n");
p.push_str(extra);
}
p
}
pub(crate) fn clean_to_cards(raw: &str) -> String {
let lines: Vec<&str> = raw.lines().collect();
let Some(start) = lines.iter().position(|l| l.starts_with('#')) else {
return raw.trim().to_string();
};
let mut end = lines.len();
while end > start + 1 {
let t = lines[end - 1].trim();
if t.is_empty() || t.starts_with("```") {
end -= 1;
} else {
break;
}
}
lines[start..end].join("\n")
}
pub fn grade_prediction(
checkpoint: &Checkpoint,
prediction: &str,
ask_cfg: &AskConfig,
) -> Result<(Delta, String)> {
let run_cfg = AskConfig {
allowed_tools: Vec::new(), cwd: None,
..ask_cfg.clone()
};
let raw = ask::run(&run_cfg, &grade_prompt(checkpoint, prediction), &[])?;
Ok(parse_grade(&raw))
}
pub fn spawn_grade(
checkpoint: Checkpoint,
prediction: String,
ask_cfg: AskConfig,
) -> Receiver<Result<(Delta, String), String>> {
let (tx, rx) = channel();
std::thread::spawn(move || {
let reply =
grade_prediction(&checkpoint, &prediction, &ask_cfg).map_err(|e| format!("{e:#}"));
let _ = tx.send(reply);
});
rx
}
fn grade_prompt(checkpoint: &Checkpoint, prediction: &str) -> String {
let points = checkpoint.points.join("\n- ");
format!(
"A learner is doing a predict-then-verify walk through a source. At this \
hop they were asked:\n\n{}\n\nA correct answer hits these KEY POINTS:\n\
- {points}\n\nThe learner PREDICTED:\n\n{prediction}\n\nGrade the \
prediction against the key points (minor wording differences are fine; \
judge the substance). Reply with EXACTLY ONE line: the verdict word, then \
a dash and ONE short sentence of feedback naming what was right or \
missing. The verdict is one of:\n\
GOT — covers the key points with nothing important wrong\n\
PARTIAL — some right, but an important point is missed, muddled, or \
stated wrongly\n\
MISSED — misses the point, or its core claim is wrong\n\
Do NOT award GOT to a prediction that asserts something the key points \
CONTRADICT — a confident error is PARTIAL at best (MISSED if the core \
claim is wrong).\n\
Example: `PARTIAL — right that it reschedules, but you missed the \
streak reset.`",
checkpoint.prompt
)
}
fn parse_grade(raw: &str) -> (Delta, String) {
let line = raw.trim().lines().next().unwrap_or("").trim();
let upper = line.to_ascii_uppercase();
let delta = if upper.starts_with("GOT") {
Delta::Got
} else if upper.starts_with("PARTIAL") {
Delta::Partial
} else {
Delta::Missed
};
let feedback = line
.split_once(['—', '-'])
.map(|(_, f)| f.trim().to_string())
.filter(|f| !f.is_empty())
.unwrap_or_else(|| line.to_string());
(delta, feedback)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Phase {
Predict,
Reveal,
Compress,
Done,
}
pub struct Walk {
trace: Trace,
scheduler: SchedulerKind,
current: usize,
phase: Phase,
predictions: Vec<String>,
deltas: Vec<Option<Delta>>,
compression: Option<String>,
}
impl Walk {
pub fn new(trace: Trace, scheduler: SchedulerKind) -> Walk {
let n = trace.checkpoints.len();
Walk {
trace,
scheduler,
current: 0,
phase: Phase::Predict,
predictions: vec![String::new(); n],
deltas: vec![None; n],
compression: None,
}
}
pub fn trace(&self) -> &Trace {
&self.trace
}
pub fn phase(&self) -> Phase {
self.phase
}
pub fn total(&self) -> usize {
self.trace.checkpoints.len()
}
pub fn current_index(&self) -> usize {
self.current
}
pub fn checkpoint(&self) -> Option<&Checkpoint> {
self.trace.checkpoints.get(self.current)
}
pub fn predict(&mut self, text: String) {
if self.phase != Phase::Predict {
return;
}
if let Some(slot) = self.predictions.get_mut(self.current) {
*slot = text;
}
self.phase = Phase::Reveal;
}
pub fn grade(&mut self, store: &mut Store, delta: Delta, now_ms: u64) {
if self.phase != Phase::Reveal {
return;
}
if let Some(checkpoint) = self.trace.checkpoints.get(self.current) {
let state = store.get_or_insert(checkpoint.card_id, now_ms);
self.scheduler
.scheduler()
.apply(state, delta.grade(), now_ms);
}
self.deltas[self.current] = Some(delta);
if self.current + 1 < self.trace.checkpoints.len() {
self.current += 1;
self.phase = Phase::Predict;
} else {
self.phase = Phase::Compress;
}
}
pub fn compress(&mut self, text: String) {
if self.phase != Phase::Compress {
return;
}
self.compression = Some(text);
self.phase = Phase::Done;
}
pub fn prediction(&self, i: usize) -> Option<&str> {
self.predictions.get(i).map(String::as_str)
}
pub fn delta(&self, i: usize) -> Option<Delta> {
self.deltas.get(i).copied().flatten()
}
pub fn compression(&self) -> Option<&str> {
self.compression.as_deref()
}
pub fn summary(&self) -> Summary {
let mut s = Summary::default();
for (i, delta) in self.deltas.iter().enumerate() {
match delta {
Some(Delta::Got) => s.got += 1,
Some(Delta::Partial) => {
s.partial += 1;
s.weak.push(i);
}
Some(Delta::Missed) => {
s.missed += 1;
s.weak.push(i);
}
None => {}
}
}
s
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Summary {
pub got: usize,
pub partial: usize,
pub missed: usize,
pub weak: Vec<usize>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Excerpt {
pub path: PathBuf,
pub lines: Vec<(usize, String)>,
pub truncated: bool,
}
pub(crate) fn resolve_source(
deck_dir: Option<&Path>,
source: Option<&str>,
) -> (PathBuf, Option<PathBuf>) {
let deck_dir = deck_dir.unwrap_or_else(|| Path::new(".")).to_path_buf();
let Some(source) = source else {
return (deck_dir, None);
};
if is_url(source) {
return (deck_dir, None);
}
let p = if Path::new(source).is_absolute() {
PathBuf::from(source)
} else {
deck_dir.join(source)
};
if p.is_file() {
let base = p.parent().map(Path::to_path_buf).unwrap_or(deck_dir);
(base, Some(p))
} else {
(p, None)
}
}
#[derive(Clone, Debug)]
pub struct SourceBase {
base_dir: PathBuf,
source_file: Option<PathBuf>,
}
impl SourceBase {
pub fn for_deck(deck: &Deck) -> Self {
let (base_dir, source_file) =
resolve_source(deck.path.parent(), deck.sources.first().map(String::as_str));
Self {
base_dir,
source_file,
}
}
pub fn excerpt(&self, locator: &str) -> Result<Excerpt> {
excerpt_at(&self.base_dir, self.source_file.as_deref(), locator)
}
}
pub(crate) fn project_root(sources: &[String], deck_dir: &Path) -> Option<PathBuf> {
let mut dirs: Vec<PathBuf> = sources
.iter()
.filter(|s| !is_url(s))
.flat_map(|s| source_paths(s, Some(deck_dir)))
.filter(|p| p.exists())
.map(|p| {
if p.is_file() {
p.parent().map(Path::to_path_buf).unwrap_or(p)
} else {
p
}
})
.collect();
dirs.sort();
dirs.dedup();
let base = common_ancestor(&dirs)?;
Some(find_project_root(&base).unwrap_or(base))
}
pub(crate) fn source_paths(value: &str, base: Option<&Path>) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut anchor: Option<PathBuf> = None;
for part in value.split(" + ").map(str::trim).filter(|p| !p.is_empty()) {
let p = Path::new(part);
let resolved = if p.is_absolute() {
p.to_path_buf()
} else {
anchor
.as_ref()
.map(|a| a.join(p))
.filter(|candidate| candidate.exists())
.or_else(|| base.map(|d| d.join(p)))
.unwrap_or_else(|| p.to_path_buf())
};
if anchor.is_none() {
anchor = resolved.parent().map(Path::to_path_buf);
}
out.push(resolved);
}
out
}
fn common_ancestor(dirs: &[PathBuf]) -> Option<PathBuf> {
let mut common = dirs.first()?.clone();
for d in &dirs[1..] {
while !d.starts_with(&common) {
common = common.parent()?.to_path_buf();
}
}
Some(common)
}
fn find_project_root(dir: &Path) -> Option<PathBuf> {
const MARKERS: [&str; 5] = [
"Cargo.toml",
".git",
"package.json",
"go.mod",
"pyproject.toml",
];
let mut cur = Some(dir);
while let Some(d) = cur {
if MARKERS.iter().any(|m| d.join(m).exists()) {
return Some(d.to_path_buf());
}
cur = d.parent();
}
None
}
fn parse_locator(locator: &str) -> (Option<String>, Option<String>) {
let locator = locator.trim();
if let Some((file, spec)) = locator.rsplit_once(':')
&& is_line_spec(spec)
{
return (Some(file.trim().to_string()), Some(spec.trim().to_string()));
}
if is_line_spec(locator) {
return (None, Some(locator.to_string()));
}
(Some(locator.to_string()), None)
}
fn is_line_spec(s: &str) -> bool {
let s = s.trim();
match s.split_once('-') {
Some((a, b)) => is_number(a) && is_number(b),
None => is_number(s),
}
}
fn is_number(s: &str) -> bool {
let s = s.trim();
!s.is_empty() && s.chars().all(|c| c.is_ascii_digit())
}
fn parse_line_range(spec: &str) -> (usize, usize) {
let parse = |s: &str| s.trim().parse::<usize>().unwrap_or(1);
let (a, b) = match spec.trim().split_once('-') {
Some((a, b)) => (parse(a), parse(b)),
None => {
let n = parse(spec);
(n, n)
}
};
if a <= b { (a, b) } else { (b, a) }
}
fn excerpt_at(base_dir: &Path, source_file: Option<&Path>, locator: &str) -> Result<Excerpt> {
let (file, spec) = parse_locator(locator);
let path = match file {
Some(f) => base_dir.join(f),
None => source_file.map(Path::to_path_buf).ok_or_else(|| {
anyhow!(
"locator `{locator}` gives only line numbers, but `% source:` \
is not a single file — write it as `file:lines`"
)
})?,
};
read_excerpt(&path, spec.as_deref())
}
fn read_excerpt(path: &Path, spec: Option<&str>) -> Result<Excerpt> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow!("cannot read the source `{}`: {e}", path.display()))?;
let file_lines: Vec<&str> = text.lines().collect();
let (start, end) = match spec {
None => (1, file_lines.len()),
Some(spec) => parse_line_range(spec),
};
let start = start.max(1);
let end = end.min(file_lines.len());
let mut selected: Vec<(usize, String)> = Vec::new();
let mut truncated = false;
for no in start..=end {
if selected.len() >= MAX_EXCERPT_LINES {
truncated = true;
break;
}
selected.push((no, file_lines[no - 1].to_string()));
}
if selected.is_empty() {
bail!(
"locator points outside `{}` ({} lines)",
path.display(),
file_lines.len()
);
}
Ok(Excerpt {
path: path.to_path_buf(),
lines: selected,
truncated,
})
}
#[cfg(test)]
mod tests {
use super::*;
fn write(dir: &Path, name: &str, body: &str) -> PathBuf {
let path = dir.join(name);
std::fs::write(&path, body).unwrap();
path
}
#[test]
fn excerpt_at_resolves_a_file_and_line_locator() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "notes.md", "alpha\nbeta\ngamma\ndelta\n");
let ex = excerpt_at(dir.path(), None, "notes.md:2-3").unwrap();
assert_eq!(
vec![(2, "beta".to_string()), (3, "gamma".to_string())],
ex.lines
);
}
#[test]
fn excerpt_at_resolves_a_line_only_locator_against_the_single_source_file() {
let dir = tempfile::tempdir().unwrap();
let file = write(dir.path(), "notes.md", "alpha\nbeta\ngamma\n");
let ex = excerpt_at(dir.path(), Some(&file), "2").unwrap();
assert_eq!(vec![(2, "beta".to_string())], ex.lines);
}
#[test]
fn excerpt_at_rejects_a_line_only_locator_without_a_single_file() {
let dir = tempfile::tempdir().unwrap();
let err = excerpt_at(dir.path(), None, "2-3").unwrap_err();
assert!(format!("{err:#}").contains("only line numbers"));
}
#[test]
fn source_base_reads_a_fact_cards_citation() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "notes.md", "one\ntwo\nthree\nfour\n");
let deck_path = dir.path().join("facts.txt");
std::fs::write(
&deck_path,
"% source: notes.md\n# q\n\ta\n\t% at: notes.md:2-3\n",
)
.unwrap();
let deck = crate::deck::Deck::load(&deck_path).unwrap();
let base = SourceBase::for_deck(&deck);
let locator = deck.cards[0].at.as_deref().expect("card carries % at:");
assert_eq!(
vec![(2, "two".to_string()), (3, "three".to_string())],
base.excerpt(locator).unwrap().lines
);
assert_eq!(
vec![(3, "three".to_string())],
base.excerpt("3").unwrap().lines
);
}
#[test]
fn delta_keys_and_grades() {
assert_eq!(Some(Delta::Got), Delta::from_key('G'));
assert_eq!(Some(Delta::Partial), Delta::from_key('p'));
assert_eq!(Some(Delta::Missed), Delta::from_key('m'));
assert_eq!(None, Delta::from_key('x'));
assert_eq!(Grade::Pass, Delta::Got.grade());
assert_eq!(Grade::Fail, Delta::Partial.grade());
assert_eq!(Grade::Fail, Delta::Missed.grade());
}
#[test]
fn parse_locator_splits_file_and_spec() {
assert_eq!(
(Some("card.rs".to_string()), Some("1-9".to_string())),
parse_locator("card.rs:1-9")
);
assert_eq!(
(
Some("src/serve.rs".to_string()),
Some("682-689".to_string())
),
parse_locator("src/serve.rs:682-689")
);
assert_eq!(
(Some("src/serve.rs:544,980".to_string()), None),
parse_locator("src/serve.rs:544,980")
);
assert_eq!(
(None, Some("151-158".to_string())),
parse_locator("151-158")
);
assert_eq!(
(Some("notes.md".to_string()), None),
parse_locator("notes.md")
);
}
#[test]
fn parse_line_range_handles_single_range_and_reversed() {
assert_eq!((1, 9), parse_line_range("1-9"));
assert_eq!((5, 5), parse_line_range("5"));
assert_eq!((8, 12), parse_line_range("12-8"));
}
#[test]
fn read_excerpt_selects_a_contiguous_span_with_line_numbers() {
let dir = tempfile::tempdir().unwrap();
let path = write(dir.path(), "f.txt", "a\nb\nc\nd\ne\n");
let ex = read_excerpt(&path, Some("2-4")).unwrap();
assert_eq!(
vec![
(2, "b".to_string()),
(3, "c".to_string()),
(4, "d".to_string())
],
ex.lines
);
assert!(!ex.truncated);
let ex = read_excerpt(&path, Some("1")).unwrap();
assert_eq!(vec![(1, "a".to_string())], ex.lines);
}
#[test]
fn read_excerpt_clamps_out_of_range_lines() {
let dir = tempfile::tempdir().unwrap();
let path = write(dir.path(), "f.txt", "a\nb\nc\n");
let ex = read_excerpt(&path, Some("2-99")).unwrap();
assert_eq!(vec![(2, "b".to_string()), (3, "c".to_string())], ex.lines);
assert!(read_excerpt(&path, Some("99")).is_err());
}
#[test]
fn read_excerpt_whole_file_caps_long_sources() {
let dir = tempfile::tempdir().unwrap();
let body: String = (1..=100).map(|n| format!("line {n}\n")).collect();
let path = write(dir.path(), "big.txt", &body);
let ex = read_excerpt(&path, None).unwrap();
assert_eq!(MAX_EXCERPT_LINES, ex.lines.len());
assert!(ex.truncated);
}
fn trace_deck(dir: &Path) -> Deck {
write(dir, "source.txt", "first\nsecond\nthird\nfourth\n");
let path = write(
dir,
"t.txt",
"% trace: how it works\n\
% source: source.txt\n\
# Predict the first hop\n\
\t% given: line — the current input line\n\
\tit reads the first line\n\
\t% at: 1\n\
\t! the entry point\n\
# Predict the second hop\n\
\tit reads lines two and three\n\
\t% at: 2-3\n",
);
Deck::load(&path).unwrap()
}
#[test]
fn from_deck_builds_checkpoints_and_rejects_non_traces() {
let dir = tempfile::tempdir().unwrap();
let deck = trace_deck(dir.path());
let trace = Trace::from_deck(&deck).unwrap();
assert_eq!("how it works", trace.description);
assert_eq!(2, trace.checkpoints.len());
assert_eq!("Predict the first hop", trace.checkpoints[0].prompt);
assert_eq!(
vec!["line — the current input line".to_string()],
trace.checkpoints[0].givens
);
assert!(trace.checkpoints[1].givens.is_empty());
assert_eq!(Some("1".to_string()), trace.checkpoints[0].locator);
assert_eq!(
Some("the entry point".to_string()),
trace.checkpoints[0].note
);
let plain = write(dir.path(), "p.txt", "# q\n\ta\n");
let err = Trace::from_deck(&Deck::load(&plain).unwrap()).unwrap_err();
assert!(format!("{err:#}").contains("not a trace"));
}
#[test]
fn excerpt_reads_live_from_the_single_source_file() {
let dir = tempfile::tempdir().unwrap();
let deck = trace_deck(dir.path());
let trace = Trace::from_deck(&deck).unwrap();
let ex = trace.excerpt(&trace.checkpoints[0]).unwrap();
assert_eq!(vec![(1, "first".to_string())], ex.lines);
let ex = trace.excerpt(&trace.checkpoints[1]).unwrap();
assert_eq!(
vec![(2, "second".to_string()), (3, "third".to_string())],
ex.lines
);
}
#[test]
fn line_only_locator_needs_a_single_source_file() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: .\n# q\n\ta\n\t% at: 1\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
let err = trace.excerpt(&trace.checkpoints[0]).unwrap_err();
assert!(format!("{err:#}").contains("not a single file"));
}
#[test]
fn lint_locators_passes_a_valid_trace() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "src.txt", "one\ntwo\nthree\nfour\nfive\n");
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: src.txt\n# q\n\ta\n\t% at: 2-3\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
assert!(trace.lint_locators().is_empty());
}
#[test]
fn lint_locators_flags_a_start_past_eof() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "src.txt", "one\ntwo\nthree\n");
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: src.txt\n# q\n\ta\n\t% at: 5-6\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
let issues = trace.lint_locators();
assert_eq!(1, issues.len());
assert_eq!(0, issues[0].checkpoint);
assert!(issues[0].message.contains("only 3"));
}
#[test]
fn lint_locators_flags_a_clamped_end() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "src.txt", "one\ntwo\nthree\n");
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: src.txt\n# q\n\ta\n\t% at: 2-9\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
let issues = trace.lint_locators();
assert_eq!(1, issues.len());
assert!(issues[0].message.contains("clamped"));
}
#[test]
fn lint_locators_flags_a_missing_file() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: .\n# q\n\ta\n\t% at: nope.rs:1-2\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
let issues = trace.lint_locators();
assert_eq!(1, issues.len());
assert!(issues[0].message.contains("not found"));
}
#[test]
fn lint_locators_flags_a_missing_locator() {
let dir = tempfile::tempdir().unwrap();
write(dir.path(), "src.txt", "one\ntwo\n");
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: src.txt\n# q\n\ta\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
let issues = trace.lint_locators();
assert_eq!(1, issues.len());
assert!(issues[0].message.contains("no `% at:`"));
}
#[test]
fn lint_locators_flags_line_only_without_a_single_file() {
let dir = tempfile::tempdir().unwrap();
let path = write(
dir.path(),
"t.txt",
"% trace: g\n% source: .\n# q\n\ta\n\t% at: 1\n",
);
let trace = Trace::from_deck(&Deck::load(&path).unwrap()).unwrap();
let issues = trace.lint_locators();
assert_eq!(1, issues.len());
assert!(issues[0].message.contains("not a single file"));
}
#[test]
fn walk_runs_predict_reveal_grade_then_compress() {
let dir = tempfile::tempdir().unwrap();
let deck = trace_deck(dir.path());
let trace = Trace::from_deck(&deck).unwrap();
let card0 = trace.checkpoints[0].card_id;
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let mut walk = Walk::new(trace, SchedulerKind::Leitner);
assert_eq!(Phase::Predict, walk.phase());
assert_eq!(2, walk.total());
walk.predict("my guess".to_string());
assert_eq!(Phase::Reveal, walk.phase());
assert_eq!(Some("my guess"), walk.prediction(0));
walk.grade(&mut store, Delta::Got, 1000);
assert_eq!(Phase::Predict, walk.phase());
assert_eq!(1, walk.current_index());
assert_eq!(2, store.get(card0).unwrap().stage);
let card1 = walk.checkpoint().unwrap().card_id;
walk.predict(String::new());
walk.grade(&mut store, Delta::Missed, 1001);
assert_eq!(Phase::Compress, walk.phase());
assert_eq!(1, store.get(card1).unwrap().stage);
walk.compress("the whole path in two sentences".to_string());
assert_eq!(Phase::Done, walk.phase());
assert_eq!(Some("the whole path in two sentences"), walk.compression());
let summary = walk.summary();
assert_eq!(1, summary.got);
assert_eq!(1, summary.missed);
assert_eq!(vec![1], summary.weak); }
#[test]
fn grade_is_a_noop_outside_reveal() {
let dir = tempfile::tempdir().unwrap();
let deck = trace_deck(dir.path());
let trace = Trace::from_deck(&deck).unwrap();
let mut store = Store::open(dir.path().join("p.json")).unwrap();
let mut walk = Walk::new(trace, SchedulerKind::Leitner);
walk.grade(&mut store, Delta::Got, 1000);
assert_eq!(Phase::Predict, walk.phase());
assert_eq!(0, walk.current_index());
assert!(store.is_empty());
}
#[test]
fn parse_grade_reads_verdict_and_feedback() {
let (d, f) = parse_grade("PARTIAL — right that it reschedules, but missed the clamp.");
assert_eq!(Delta::Partial, d);
assert_eq!("right that it reschedules, but missed the clamp.", f);
assert_eq!(Delta::Got, parse_grade("GOT — spot on").0);
assert_eq!(Delta::Missed, parse_grade("MISSED - wrong direction").0);
assert_eq!(Delta::Missed, parse_grade("hmm not sure").0);
}
#[test]
fn build_prompt_carries_goal_source_format_and_rules() {
let p = build_prompt("how X becomes Y", ".", false, &TraceConfig::default());
assert!(p.contains("how X becomes Y"));
assert!(p.contains("Source (the scope): ."));
assert!(p.contains("Read, Glob")); assert!(p.contains("file:start-end")); assert!(p.contains("ONE CONTIGUOUS RANGE")); assert!(p.contains("# <the question")); assert!(p.contains("% at:"));
assert!(p.contains("black-box hop")); assert!(p.contains("free variable")); assert!(p.contains("% given:")); assert!(p.contains("MUST be COMPLETE")); assert!(p.contains("does NOT BIND")); assert!(p.contains("KEY POINTS MUST BE GROUNDED")); assert!(p.contains("One path, not a set"));
assert!(p.contains("Carry the STATE"));
assert!(p.contains("Do NOT prefix fronts with \"Predict\""));
assert!(p.contains("Dives must return"));
assert!(p.contains("must TEACH")); assert!(p.contains("stay on the SPINE")); assert!(p.contains("EVERY instance travels")); assert!(p.contains("self-documenting")); assert!(!p.contains("WebFetch")); }
#[test]
fn suggest_prompt_recons_for_a_menu_without_tracing() {
let p = suggest_prompt(".", false, &TraceConfig::default());
assert!(p.contains("RECON")); assert!(p.contains("Do NOT trace any path in depth")); assert!(p.contains("ranked MENU")); assert!(p.contains("path-QUESTION")); assert!(p.contains("COVERAGE, NOT A COUNT")); assert!(p.contains("per major subsystem")); assert!(p.contains("EDGES vs NODES")); assert!(p.contains("Skipped (node-shaped")); assert!(!p.contains("5–8")); assert!(p.contains("by centrality")); assert!(p.contains("spine:")); assert!(p.contains("NOT cited checkpoints")); assert!(p.contains("a \"goal\"")); assert!(p.contains("Read, Glob")); assert!(!p.contains("WebFetch")); assert!(!p.contains("% at:")); }
#[test]
fn suggest_prompt_url_uses_webfetch() {
let p = suggest_prompt("https://x", true, &TraceConfig::default());
assert!(p.contains("WebFetch"));
assert!(!p.contains("Glob")); }
#[test]
fn build_prompt_url_uses_webfetch_and_quoted_span() {
let p = build_prompt("how X", "https://x", true, &TraceConfig::default());
assert!(p.contains("WebFetch"));
assert!(p.contains("quoted span"));
assert!(!p.contains("Glob")); }
#[test]
fn build_prompt_appends_extra() {
let cfg = TraceConfig {
extra: Some("trace the read path".to_string()),
..TraceConfig::default()
};
let p = build_prompt("g", ".", false, &cfg);
assert!(p.contains("Additional instructions:"));
assert!(p.contains("trace the read path"));
}
#[test]
fn build_run_config_uses_readonly_tools_and_cwd() {
let cwd = PathBuf::from("/some/src");
let cfg = build_run_config(
&TraceConfig::default(),
&AskConfig::default(),
Some(cwd.clone()),
false,
);
assert_eq!(vec!["Read", "Glob", "Grep"], cfg.allowed_tools);
assert_eq!(Some(cwd), cfg.cwd);
assert_eq!(600, cfg.timeout_secs); }
#[test]
fn project_root_walks_up_to_the_crate_root() {
let dir = tempfile::tempdir().unwrap();
let crate_dir = dir.path().join("mycrate");
std::fs::create_dir_all(crate_dir.join("src")).unwrap();
std::fs::write(crate_dir.join("Cargo.toml"), "[package]\nname=\"x\"\n").unwrap();
std::fs::write(crate_dir.join("README.md"), "# x\n").unwrap();
std::fs::write(crate_dir.join("src/lib.rs"), "// lib\n").unwrap();
let s = |p: PathBuf| p.to_string_lossy().into_owned();
let both = vec![
s(crate_dir.join("README.md")),
s(crate_dir.join("src/lib.rs")),
];
assert_eq!(Some(crate_dir.clone()), project_root(&both, dir.path()));
let only = vec![s(crate_dir.join("src/lib.rs"))];
assert_eq!(Some(crate_dir.clone()), project_root(&only, dir.path()));
assert_eq!(
None,
project_root(&["https://example.com".to_string()], dir.path())
);
}
#[test]
fn source_paths_splits_plus_and_anchors_relative_parts() {
let dir = tempfile::tempdir().unwrap();
let crate_dir = dir.path().join("crate");
std::fs::create_dir_all(crate_dir.join("src")).unwrap();
std::fs::write(crate_dir.join("README.md"), "r").unwrap();
std::fs::write(crate_dir.join("src/lib.rs"), "l").unwrap();
let value = format!("{}/README.md + src/lib.rs", crate_dir.display());
assert_eq!(
vec![crate_dir.join("README.md"), crate_dir.join("src/lib.rs")],
source_paths(&value, Some(dir.path()))
);
let one = crate_dir.join("src/lib.rs");
assert_eq!(
vec![one.clone()],
source_paths(&one.to_string_lossy(), None)
);
}
#[test]
fn clean_to_cards_strips_fence_and_preamble() {
let raw = "Here is the trace:\n```text\n# Q1\n\tp\n\t% at: 1\n```";
assert_eq!("# Q1\n\tp\n\t% at: 1", clean_to_cards(raw));
}
use crate::testutil::{ask_config, exec_lock, fake_reply};
#[test]
fn build_end_to_end_returns_cleaned_cards() {
let _lock = exec_lock();
let dir = tempfile::tempdir().unwrap();
let cli = fake_reply(dir.path(), "# Q1\n\tp1\n\t% at: 1\n# Q2\n\tp2\n\t% at: 2\n");
let path = write(dir.path(), "t.txt", "% trace: how it works\n% source: .\n");
let deck = Deck::load(&path).unwrap();
let cards = build(&deck, &TraceConfig::default(), &ask_config(&cli)).unwrap();
assert!(cards.starts_with("# Q1"));
assert!(cards.contains("# Q2"));
assert!(cards.contains("% at: 2"));
}
fn snapshot_workspace(root: &Path) -> PathBuf {
std::fs::create_dir_all(root.join("src")).unwrap();
write(&root.join("src"), "a.rs", "alpha\nbeta\ngamma\n");
write(&root.join("src"), "b.rs", "one\ntwo\n");
std::fs::create_dir_all(root.join("ws")).unwrap();
write(
&root.join("ws"),
"alix.toml",
"title = \"W\"\n\n[defaults]\n",
);
write(
&root.join("ws"),
"t.txt",
"% trace: how it works\n\
% source: ../src\n\
# hop 1\n\tit reads a\n\t% at: a.rs:2-3\n\
# hop 2\n\tit reads b\n\t% at: b.rs:1\n",
)
}
#[test]
fn snapshot_freezes_excerpts_with_provenance() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let deck_path = snapshot_workspace(root);
let deck = Deck::load(&deck_path).unwrap();
let report = snapshot(&deck, 0).unwrap();
assert_eq!(2, report.copied.len());
assert!(report.missing.is_empty());
assert!(root.join("ws/assets/01.rs").is_file());
assert!(root.join("ws/assets/02.rs").is_file());
assert!(!root.join("ws/assets/a.rs").exists());
assert_eq!(
"beta\ngamma\n",
std::fs::read_to_string(root.join("ws/assets/01.rs")).unwrap()
);
let text = std::fs::read_to_string(&deck_path).unwrap();
assert!(text.contains("% source: assets\n"), "{text}");
assert!(text.contains("% at: 01.rs\n"), "{text}"); assert!(text.contains("! from a.rs:2-3\n"), "{text}"); assert!(!text.contains("a.rs:2-3\n\t%")); assert!(!text.contains("! from b.rs"));
let frozen = Deck::load(&deck_path).unwrap();
let trace = Trace::from_deck(&frozen).unwrap();
let ex = trace.excerpt(&trace.checkpoints[0]).unwrap();
assert_eq!(
vec![(1, "beta".to_string()), (2, "gamma".to_string())],
ex.lines
);
}
#[test]
fn snapshot_drift_is_gone_after_editing_upstream() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let deck_path = snapshot_workspace(root);
snapshot(&Deck::load(&deck_path).unwrap(), 0).unwrap();
std::fs::write(root.join("src/a.rs"), "TOTALLY\nDIFFERENT\n").unwrap();
let trace = Trace::from_deck(&Deck::load(&deck_path).unwrap()).unwrap();
let ex = trace.excerpt(&trace.checkpoints[0]).unwrap();
assert_eq!(
vec![(1, "beta".to_string()), (2, "gamma".to_string())],
ex.lines
);
}
#[test]
fn snapshot_freezes_single_file_source() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write(root, "notes.md", "L1\nL2\nL3\n");
std::fs::create_dir_all(root.join("ws")).unwrap();
write(&root.join("ws"), "alix.toml", "[defaults]\n");
let deck_path = write(
&root.join("ws"),
"t.txt",
"% trace: t\n% source: ../notes.md\n# hop\n\tp\n\t% at: 2\n",
);
let report = snapshot(&Deck::load(&deck_path).unwrap(), 0).unwrap();
assert_eq!(1, report.copied.len());
assert!(root.join("ws/assets/01.md").is_file());
let text = std::fs::read_to_string(&deck_path).unwrap();
assert!(text.contains("% source: assets\n"), "{text}");
assert!(text.contains("% at: 01.md\n"), "{text}");
assert!(text.contains("! from notes.md:2\n"), "{text}");
let frozen = Deck::load(&deck_path).unwrap();
let trace = Trace::from_deck(&frozen).unwrap();
let ex = trace.excerpt(&trace.checkpoints[0]).unwrap();
assert_eq!(vec![(1, "L2".to_string())], ex.lines);
}
#[test]
fn snapshot_refuses_non_workspace_and_url() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let loose = write(
root,
"t.txt",
"% trace: t\n% source: .\n# h\n\tp\n\t% at: x.rs:1\n",
);
let err = snapshot(&Deck::load(&loose).unwrap(), 0).unwrap_err();
assert!(format!("{err:#}").contains("not in a workspace"), "{err:#}");
std::fs::create_dir_all(root.join("ws")).unwrap();
write(&root.join("ws"), "alix.toml", "[defaults]\n");
let url = write(
&root.join("ws"),
"u.txt",
"% trace: t\n% source: https://example.com/p\n# h\n\tp\n\t% at: 1\n",
);
let err = snapshot(&Deck::load(&url).unwrap(), 0).unwrap_err();
assert!(format!("{err:#}").contains("URL"), "{err:#}");
}
}