use std::{
collections::HashMap,
fs,
path::{Path, PathBuf},
};
use anyhow::{Context, Result, anyhow, bail};
use crate::{
ask,
backend::ensure_source_reachable,
config::{AskConfig, TraceConfig},
deck::{Deck, is_url},
generate::{self, GenerationSpec},
library,
parser::yaml_quote,
share,
source::{self, resolve_source},
store::Store,
title,
trace_ai::{build_run_config, clean_to_cards},
workspace,
};
pub fn explore(
source: &str,
spec: &GenerationSpec,
cfg: &TraceConfig,
ask_cfg: &AskConfig,
) -> Result<String> {
let url = is_url(source);
ensure_source_reachable(ask_cfg, url)?;
let cwd = if url {
None
} else {
let (base_dir, _) = resolve_source(None, Some(source));
Some(base_dir)
};
let prompt = explore_prompt(source, spec, url, cfg);
let run_cfg = build_run_config(cfg, ask_cfg, cwd, url);
let raw = ask::run(&run_cfg, &prompt, &[])?;
let plan = raw.trim().to_string();
if plan.is_empty() {
bail!("the exploration produced no plan");
}
Ok(plan)
}
fn explore_prompt(source: &str, spec: &GenerationSpec, url: bool, cfg: &TraceConfig) -> String {
let goal = &spec.goal;
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 path (a subdirectory or single file) \
for a tightly-scoped item"
};
let mut p = format!(
"You are EXPLORING a source for a learner whose GOAL is:\n\n {goal}\n\n\
Produce the ordered SET OF MEANS — facts decks and traces — that, \
worked through in order, would achieve that goal. Do NOT build any deck or \
trace in depth (no cards, no checkpoints) — that is a separate step the \
learner runs later on each item. Output a PLAN: an ordered list of \
items.\n\n\
Source (the scope): {source}\n{explore}\n\n\
TWO KINDS OF MEANS, chosen by the SHAPE of the knowledge:\n\
- a TRACE drills an EDGE — a path predicted hop by hop, \"how X becomes \
Y\": a data flow, a control flow, or a derivation, a real sequence with two \
ends. Use a trace where the knowledge IS a path.\n\
- a DECK drills NODES — a table of related facts with no path to predict (a \
config's knobs, an on-disk format, a glossary of terms). Use a deck where \
the knowledge is a SET OF FACTS.\n\
Pick the form that fits each part; never force facts into a fake path, nor \
a real path into loose facts.\n\n\
COVERAGE is sized to the GOAL, not to a number. Identify the parts of the \
source the goal requires understanding, and emit one item per part, \
choosing trace or deck by shape. STOP at SATURATION: when one more item \
would teach no new mechanism or concept the learner has not already met. A \
broad goal (\"understand the whole X\") covers every major subsystem; a \
narrow goal (\"how Y works\") covers only Y's parts. Do not pad to look \
thorough; do not drop a part the goal needs.\n\n\
ORDER by PREREQUISITE. Sort so that whatever must be understood FIRST comes \
first — the foundational data model and parsing before the flow that uses \
them before the outer surfaces. Give each item a `requires:` list naming \
the earlier item numbers it builds on (none for the foundations). The order \
must be a valid TOPOLOGICAL order: every item's requirements appear above \
it.\n\n\
FORMAT — output ONLY the plan, no preamble, no code fences. Start with \
three heading lines, then the numbered items:\n\n\
Goal {goal}\n\
Source <one line: what this source is>\n\
Spine <the single most central path, arrow-joined nouns>\n\n\
1. [deck] <a short topic noun phrase, e.g. the deck format>\n \
requires: none\n @source: <{scope}>\n\
2. [trace] <the path-question, e.g. how deck text becomes a list of Cards>\n \
requires: 1\n @source: <{scope}>\n\
3. …\n\n\
Tag EVERY item [trace] or [deck]. Keep each title SHORT — one line, a \
handful of words: a [deck] is a noun phrase naming the topic (`the crate \
surface`, `error taxonomy`), a [trace] is a terse path-question (`how a \
request becomes a profile`). Do NOT pack the contents into the title — no \
`X: a, b, and c` enumerations, no parenthetical lists; the cards hold the \
detail. Do not resolve line numbers or write cards/checkpoints — later \
steps do that. Use as many items as the goal needs (stop at saturation), \
ordered by prerequisite.\n\n\
`@source:` FORMAT: a [deck]'s source must be actual FILE path(s) (the \
exam reads them) — never a bare directory. For several files, join them \
with ` + `, writing the FIRST as a full path and the rest RELATIVE to its \
directory, e.g. `@source: <root>/README.md + src/lib.rs`. A [trace] may \
use a single directory or file as its locator base."
);
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.push_str("\n\n");
p.push_str(&spec.requirements());
p
}
pub fn walk(
source: &str,
spec: &GenerationSpec,
cfg: &TraceConfig,
ask_cfg: &AskConfig,
) -> Result<String> {
let url = is_url(source);
ensure_source_reachable(ask_cfg, url)?;
let cwd = if url {
None
} else {
let (base_dir, _) = resolve_source(None, Some(source));
Some(base_dir)
};
let prompt = walk_prompt(source, spec, 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 explore walk produced no checkpoints");
}
Ok(cards)
}
fn walk_prompt(source: &str, spec: &GenerationSpec, url: bool, cfg: &TraceConfig) -> String {
let goal = &spec.goal;
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 the manifest, the module/file names, the entry point, \
and the most central file. You have no write or shell access."
.to_string()
};
let locator = if url {
"a short quoted span from the page — the exact words the answer rests on"
} else {
"ONE contiguous range, `file:start-end`, relative to the source root (e.g. \
`Cargo.toml:8-20` or `src/lib.rs:12-33`) — never comma-separated"
};
let mut p = format!(
"You are building an EXPLORE walk: a short predict-and-verify trace that \
teaches a newcomer the SHAPE of a source by making them PREDICT its \
structure before each reveal. The learner's aim:\n\n {goal}\n\n{explore}\n\n\
Walk it the way you read a codebase cold, one hop per step:\n\
1. from the manifest / dependencies — what KIND of thing is this?\n\
2. from the module / file names — what are its core DOMAIN NOUNS (the model)?\n\
3. from the entry point — how is it DRIVEN (its commands / surfaces)?\n\
4. from the most central file — what is the SPINE (the main path data takes)?\n\
5. (last) given that shape, what are the first PATHS worth tracing next? — \
name 2-4 concrete candidate traces (the menu).\n\n\
Each hop must CITE REAL STRUCTURAL EVIDENCE as its `at:` locator — the \
actual lines the answer rests on (the manifest's dependency list, the \
module-declaration lines, the entry enum, the central function's signature). \
The reveal is the real text; the source is the oracle, never invented. Every \
hop has a locator.\n\n\
FORMAT — output ONLY the checkpoint cards: no frontmatter, no `trace:`/\
`source:` key, no preamble, no code fences. Each checkpoint:\n\n\
## <the shape question, asked plainly>\n\
<a key point a correct answer hits>\n\
<another key point>\n\
<!-- at: <locator> -->\n\
> [!NOTE]\n\
> <one connecting insight>\n\n\
The `## ` front is at column 0, never indented; the key points are plain \
unindented lines below it; where the `at:` locator is {locator}.\n\n\
RULES: each question reasons FORWARD from the previous reveal (hop 1 has no \
prior); carry the STATE, never \"checkpoint N\"; ask plainly — do NOT prefix \
with \"Predict\"; keep the answer out of the question; every key point must be \
GROUNDED in the cited lines; the LAST hop lands on the candidate traces (the \
menu of what to trace first). Keep each question one or two sentences and each \
key point one line. Use 4-6 checkpoints."
);
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.push_str("\n\n");
p.push_str(&spec.learner_requirements());
p
}
#[cfg_attr(coverage_nightly, coverage(off))]
pub fn explore_and_fill(
source: &str,
spec: &GenerationSpec,
cfg: &TraceConfig,
ask_cfg: &AskConfig,
) -> Result<(String, HashMap<usize, String>)> {
let url = is_url(source);
ensure_source_reachable(ask_cfg, url)?;
let cwd = if url {
None
} else {
let (base_dir, _) = resolve_source(None, Some(source));
Some(base_dir)
};
let run_cfg = build_run_config(cfg, ask_cfg, cwd, url);
let mut session = ask::CliSession::new();
let plan = ask::run(
&run_cfg,
&explore_prompt(source, spec, url, cfg),
&session.args(),
)?
.trim()
.to_string();
session.started = true;
if plan.is_empty() {
bail!("the exploration produced no plan");
}
let items = parse_plan(&plan);
if items.is_empty() {
bail!("the plan has no items to fill");
}
let filled = ask::run(&run_cfg, &fill_prompt(&items, spec), &session.args())?;
let filled = parse_filled(&filled);
validate_filled_card_styles(&items, &filled, spec)?;
Ok((plan, filled))
}
fn validate_filled_card_styles(
items: &[Item],
filled: &HashMap<usize, String>,
spec: &GenerationSpec,
) -> Result<()> {
for item in items.iter().filter(|item| item.kind == Kind::Deck) {
if let Some(deck) = filled.get(&item.num) {
let assembled = format!("---\nchoices: single\n---\n{deck}");
generate::validate_card_style(&assembled, spec)
.with_context(|| format!("generated workspace item {}", item.num))?;
}
}
Ok(())
}
fn fill_prompt(items: &[Item], spec: &GenerationSpec) -> String {
let mut list = String::new();
for item in items {
let kind = match item.kind {
Kind::Trace => "trace",
Kind::Deck => "deck",
};
list.push_str(&format!("{}. [{}] {}\n", item.num, kind, item.title));
}
format!(
"You just explored this source and produced the plan below. Now WRITE THE \
FULL CONTENT for EVERY item, reusing what you already learned — only read \
more if you must verify a line number. Because you are writing the whole \
set at once, make it COHERENT: an item must NOT re-teach what an earlier \
item (its prerequisite) covers — build on it, keep terminology consistent, \
and don't overlap.\n\n\
For each item, emit a delimiter line exactly `=== item <N> ===` (its plan \
number) followed by its content:\n\
- a [trace] → the predict-verify CHECKPOINT cards: each is a `## ` question \
at column 0 (never indented), then its key points as plain unindented \
lines, an `<!-- at: file:start-end -->` locator line citing the REAL \
lines, and an optional `> [!NOTE]` note. Each hop opens on the previous reveal, \
predicts forward, and its key points are grounded in the cited lines.\n\
- a [deck] → FACT cards: each is a `## ` front at column 0, followed by \
the required deck-card shape below, plus an `<!-- at: file:start-end -->` \
locator line citing the REAL lines whenever the fact maps to a specific \
range (so the card can show its source on reveal; omit it when the fact \
synthesizes across several places). One fact per card, concise and \
recall-oriented. Do NOT cram an enumeration into one prose answer: if the \
answer is a list of several items, split it into several one-idea cards \
(one card per item or group); keep an atomic answer atomic.\n\n\
{card_format}\n\n\
The selected card style applies to [deck] items. [trace] items retain \
their predict-and-verify checkpoint shape.\n\n\
Every `at:` `file` part MUST be written relative to the SAME root — the \
source root you explored (your working directory) — as ONE consistent path \
per file across ALL items; never drop or add a leading directory (always \
`src/foo.rs`, never sometimes `foo.rs`), so the frozen citations all resolve.\n\n\
Do NOT emit any frontmatter (`trace:`, `source:`, `requires:` keys) or a \
`# ` title — those are already written; output only the `## ` cards. \
Output ONLY the delimited item bodies: no preamble, no code fences, nothing \
between the last card of one item and the next `=== item ===` line.\n\n\
{requirements}\n\n\
The plan:\n{list}",
card_format = generate::card_format(spec.card_style),
requirements = spec.requirements(),
)
}
pub(crate) fn parse_filled(raw: &str) -> HashMap<usize, String> {
let mut out: HashMap<usize, String> = HashMap::new();
let mut current: Option<usize> = None;
let mut buf = String::new();
for line in raw.lines() {
if let Some(num) = parse_item_delimiter(line) {
if let Some(n) = current.take() {
let body = buf.trim();
if !body.is_empty() {
out.insert(n, body.to_string());
}
}
buf.clear();
current = Some(num);
} else if current.is_some() {
buf.push_str(line);
buf.push('\n');
}
}
if let Some(n) = current {
let body = buf.trim();
if !body.is_empty() {
out.insert(n, body.to_string());
}
}
out
}
fn parse_item_delimiter(line: &str) -> Option<usize> {
line.trim()
.strip_prefix("=== item")?
.trim()
.strip_suffix("===")?
.trim()
.parse()
.ok()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Item {
pub num: usize,
pub kind: Kind,
pub title: String,
pub requires: Vec<usize>,
pub source: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Kind {
Trace,
Deck,
}
pub struct Materialized {
pub dir: PathBuf,
pub traces: usize,
pub decks: usize,
pub filled: usize,
}
pub fn parse_plan(plan: &str) -> Vec<Item> {
let mut items: Vec<Item> = Vec::new();
for line in plan.lines() {
let t = line.trim();
if let Some((num, kind, title)) = parse_item_header(t) {
items.push(Item {
num,
kind,
title,
requires: Vec::new(),
source: String::new(),
});
} else if let Some(item) = items.last_mut() {
if let Some(rest) = t.strip_prefix("requires:") {
item.requires = rest
.split(|c: char| !c.is_ascii_digit())
.filter_map(|p| p.parse().ok())
.collect();
} else if let Some(rest) = t.strip_prefix("@source:") {
item.source = rest.trim().to_string();
}
}
}
items
}
fn parse_item_header(t: &str) -> Option<(usize, Kind, String)> {
let dot = t.find('.')?;
let num: usize = t[..dot].trim().parse().ok()?;
let rest = t[dot + 1..].trim_start().strip_prefix('[')?;
let close = rest.find(']')?;
let kind = match rest[..close].trim() {
"trace" => Kind::Trace,
"deck" => Kind::Deck,
_ => return None,
};
let title = rest[close + 1..].trim().to_string();
(!title.is_empty()).then_some((num, kind, title))
}
pub fn materialize(
plan: &str,
dir: &Path,
goal: &str,
title: Option<&str>,
source: &str,
public_url: Option<&str>,
filled: Option<&HashMap<usize, String>>,
) -> Result<Materialized> {
let mut items = parse_plan(plan);
if items.is_empty() {
bail!("the plan has no items to materialize");
}
for item in &mut items {
item.title = title::condense(&item.title);
}
let member_dir = dir.join(crate::workspace::DECKS);
fs::create_dir_all(&member_dir).with_context(|| format!("cannot create {}", dir.display()))?;
let root = if is_url(source) {
None
} else {
Some(fs::canonicalize(source).unwrap_or_else(|_| PathBuf::from(source)))
};
let names: Vec<String> = items.iter().map(file_name).collect();
let by_num: HashMap<usize, &String> = items
.iter()
.zip(&names)
.map(|(it, n)| (it.num, n))
.collect();
let mut manifest = String::from("# Generated by `alix workspace generate`.\n");
if let Some(title) = title {
manifest.push_str(&format!("title = \"{}\"\n", toml_escape(title)));
}
manifest.push_str(&format!("description = \"{}\"\n", toml_escape(goal)));
if let Some(source) = public_url.or_else(|| root.as_ref().and_then(|path| path.to_str())) {
manifest.push_str(&format!("source = \"{}\"\n", toml_escape(source)));
}
fs::write(dir.join(crate::workspace::MANIFEST), manifest)?;
let mut traces = 0;
let mut decks = 0;
let mut filled_count = 0;
for (item, name) in items.iter().zip(&names) {
let mut body = String::from("---\n");
match item.kind {
Kind::Trace => {
traces += 1;
body.push_str(&format!("trace: {}\n", yaml_quote(&item.title)));
}
Kind::Deck => {
decks += 1;
body.push_str("choices: single\n");
}
}
match rewrite_scope(&item.source, root.as_deref()).as_slice() {
[] => {}
[only] => body.push_str(&format!("source: {}\n", yaml_quote(only))),
many => {
body.push_str("source:\n");
for entry in many {
body.push_str(&format!(" - {}\n", yaml_quote(entry)));
}
}
}
let deps: Vec<&&String> = item
.requires
.iter()
.filter_map(|req| by_num.get(req))
.collect();
if !deps.is_empty() {
body.push_str("requires:\n");
for dep in deps {
body.push_str(&format!(" - {}\n", yaml_quote(dep)));
}
}
body.push_str("\n---\n");
if item.kind == Kind::Deck {
body.push_str(&format!("# {}\n", item.title));
}
body.push('\n');
match filled
.and_then(|f| f.get(&item.num))
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
Some(content) => {
filled_count += 1;
body.push_str(content);
body.push('\n');
}
None => body.push_str(match item.kind {
Kind::Trace => {
"Stub from `alix workspace generate`. Build the path: alix deck generate <this file>\n"
}
Kind::Deck => {
"Stub from `alix workspace generate`. Author cards here, or `alix deck generate` from the source.\n"
}
}),
}
fs::write(member_dir.join(name), body)?;
}
Ok(Materialized {
dir: dir.to_path_buf(),
traces,
decks,
filled: filled_count,
})
}
pub struct MergeReport {
pub moved: usize,
pub conflicts: Vec<String>,
}
fn merge_rank(name: &std::ffi::OsStr) -> u8 {
if name == crate::assets::ROOT {
0
} else if name == crate::workspace::DECKS {
2
} else {
1
}
}
pub fn merge_built(
staging: &Path,
dest: &Path,
force: bool,
store: &mut Store,
) -> Result<MergeReport> {
fs::create_dir_all(dest).with_context(|| format!("cannot create {}", dest.display()))?;
let mut moved = 0;
let mut conflicts = Vec::new();
let mut entries: Vec<_> = fs::read_dir(staging)
.with_context(|| format!("cannot read {}", staging.display()))?
.collect::<std::io::Result<_>>()?;
entries.sort_by_key(|entry| merge_rank(&entry.file_name()));
for entry in entries {
let name = entry.file_name().to_string_lossy().into_owned();
let from = entry.path();
let to = dest.join(&name);
if name == crate::assets::ROOT && from.is_dir() {
moved += merge_assets(&from, &to, force, &mut conflicts)?;
continue;
}
if name == crate::workspace::DECKS && from.is_dir() {
fs::create_dir_all(&to).with_context(|| format!("cannot create {}", to.display()))?;
for member in
fs::read_dir(&from).with_context(|| format!("cannot read {}", from.display()))?
{
let member = member?;
let member_name = member.file_name().to_string_lossy().into_owned();
let member_from = member.path();
let member_to = to.join(&member_name);
if member_to.exists() {
if !force {
conflicts.push(format!("{name}/{member_name}"));
continue;
}
let text = fs::read_to_string(&member_from)
.with_context(|| format!("cannot read {}", member_from.display()))?;
library::replace_deck(&to, &member_name, &text, store)?;
fs::remove_file(&member_from)
.with_context(|| format!("cannot remove {}", member_from.display()))?;
} else {
share::land_into(&member_from, &member_to)?;
}
moved += 1;
}
continue;
}
if to.exists() {
if !force {
conflicts.push(name);
continue;
}
if to.is_file() && from.is_file() && name.ends_with(".md") {
let text = fs::read_to_string(&from)
.with_context(|| format!("cannot read {}", from.display()))?;
library::replace_deck(dest, &name, &text, store)?;
fs::remove_file(&from)
.with_context(|| format!("cannot remove {}", from.display()))?;
moved += 1;
continue;
}
if to.is_dir() {
fs::remove_dir_all(&to)
.with_context(|| format!("cannot remove {}", to.display()))?;
} else {
fs::remove_file(&to).with_context(|| format!("cannot remove {}", to.display()))?;
}
}
share::land_into(&from, &to)?;
moved += 1;
}
Ok(MergeReport { moved, conflicts })
}
fn merge_assets(from: &Path, to: &Path, force: bool, conflicts: &mut Vec<String>) -> Result<usize> {
fs::create_dir_all(to).with_context(|| format!("cannot create {}", to.display()))?;
let mut moved = 0;
for entry in fs::read_dir(from).with_context(|| format!("cannot read {}", from.display()))? {
let entry = entry?;
let source = entry.path();
let destination = to.join(entry.file_name());
if source.is_dir() {
fs::create_dir_all(&destination)
.with_context(|| format!("cannot create {}", destination.display()))?;
for object in fs::read_dir(&source)? {
let object = object?;
let target = destination.join(object.file_name());
if target.is_file() {
if fs::read(object.path())? != fs::read(&target)? {
bail!(
"content-addressed asset {} has different bytes in the destination",
target.display()
);
}
fs::remove_file(object.path())?;
} else {
share::move_into(&object.path(), &target)?;
moved += 1;
}
}
let _ = fs::remove_dir(&source);
continue;
}
if destination.exists() && !force {
conflicts.push(format!(
"{}/{}",
crate::assets::ROOT,
entry.file_name().to_string_lossy()
));
continue;
}
if destination.exists() {
fs::remove_file(&destination)?;
}
share::move_into(&source, &destination)?;
moved += 1;
}
let _ = fs::remove_dir(from);
Ok(moved)
}
#[derive(Debug, Default)]
pub struct FreezeSummary {
pub decks: usize,
pub files: usize,
pub failed: Vec<String>,
}
pub fn freeze_workspace(dir: &Path) -> Result<FreezeSummary> {
let mut summary = FreezeSummary::default();
let ws = workspace::Workspace::load(dir)?;
let mut members = ws.members;
members.extend(workspace::uninitialized_deck_files(dir)?);
members.sort();
members.dedup();
for member in members {
let text = match std::fs::read_to_string(&member) {
Ok(text) => text,
Err(_) => continue,
};
let Ok(deck) = Deck::load(&member) else {
continue;
};
if deck.deck_token.is_none() && deck.cards.is_empty() {
continue;
}
let has_local_image = crate::parser::image_references(&text)
.iter()
.any(|image| !crate::deck::is_url(&image.source));
if deck.deck_token.is_some() && deck.sources.is_empty() && !has_local_image {
continue;
}
let result = if deck.deck_token.is_some() {
crate::assets::freeze_member(&member).map_err(|error| anyhow!("{error:#}"))
} else {
crate::assets::initialize(&member)
.map(|report| report.freeze.unwrap_or_default())
.map_err(|error| anyhow!("{error:#}"))
};
match result {
Ok(report) => {
let files = report.evidence + report.images;
if files > 0 {
summary.decks += 1;
summary.files += files;
}
}
Err(error) => summary
.failed
.push(format!("{}: {error:#}", member.display())),
}
}
Ok(summary)
}
fn file_name(item: &Item) -> String {
format!("{:02}-{}.md", item.num, slug(&item.title))
}
fn slug(title: &str) -> String {
let words: Vec<String> = title
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|w| !w.is_empty())
.take(6)
.map(str::to_ascii_lowercase)
.collect();
if words.is_empty() {
"item".to_string()
} else {
words.join("-")
}
}
fn rewrite_scope(scope: &str, root: Option<&Path>) -> Vec<String> {
let scope = scope.trim();
if is_url(scope) || root.is_none() {
return scope
.split(" + ")
.map(str::trim)
.filter(|part| !part.is_empty())
.map(str::to_string)
.collect();
}
let root = root.unwrap_or_else(|| Path::new("."));
let mut out = Vec::new();
let mut anchor: Option<PathBuf> = None;
for part in scope
.split(" + ")
.map(str::trim)
.filter(|part| !part.is_empty())
{
let resolved = if part == "." {
root.to_path_buf()
} else if Path::new(part).is_absolute() {
PathBuf::from(part)
} else {
anchor
.as_ref()
.map(|anchor| anchor.join(part))
.filter(|candidate| candidate.exists())
.unwrap_or_else(|| source::resolve_under_base(root, part))
};
if anchor.is_none() {
anchor = resolved.parent().map(Path::to_path_buf);
}
out.push(resolved.display().to_string());
}
out
}
fn toml_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
#[cfg(test)]
mod tests {
use super::*;
fn spec(goal: &str) -> GenerationSpec {
GenerationSpec {
goal: goal.to_string(),
language: None,
audience: None,
card_style: crate::config::GenerateCardStyle::Mixed,
}
}
#[test]
fn explore_prompt_carries_goal_means_coverage_and_order() {
let p = explore_prompt(
".",
&spec("understand the alix repo"),
false,
&TraceConfig::default(),
);
assert!(p.contains("understand the alix repo"));
assert!(p.contains("SET OF MEANS"));
assert!(p.contains("[trace]"));
assert!(p.contains("[deck]"));
assert!(p.contains("SHAPE"));
assert!(p.contains("SATURATION"));
assert!(p.contains("requires:"));
assert!(p.contains("TOPOLOGICAL order"));
assert!(p.contains("no cards, no checkpoints"));
assert!(p.contains("Read, Glob"));
assert!(!p.contains("WebFetch"));
}
#[test]
fn explore_prompt_url_uses_webfetch() {
let p = explore_prompt(
"https://x",
&spec("understand the page"),
true,
&TraceConfig::default(),
);
assert!(p.contains("WebFetch"));
assert!(!p.contains("Glob"));
}
#[test]
fn walk_prompt_example_writes_a_badged_note() {
let prompt = walk_prompt(
".",
&spec("understand the source"),
false,
&TraceConfig::default(),
);
assert!(
prompt.contains("> [!NOTE]\n> <one connecting insight>"),
"the example must emit a note rather than answer quotation: {prompt}"
);
}
#[test]
fn fill_prompt_asks_fact_cards_to_cite_their_source() {
let items = vec![
Item {
num: 1,
kind: Kind::Deck,
title: "Basics".to_string(),
requires: Vec::new(),
source: ".".to_string(),
},
Item {
num: 2,
kind: Kind::Trace,
title: "The path".to_string(),
requires: vec![1],
source: ".".to_string(),
},
];
let p = fill_prompt(&items, &spec("understand the source"));
assert!(p.contains("FACT cards"));
assert!(p.contains("<!-- at: file:start-end -->"));
assert!(!p.contains("TAB-indented"));
assert!(p.contains("show its source on reveal"));
assert!(p.contains("relative to the SAME root"));
assert!(p.contains("enumeration"));
assert!(p.contains("split it"));
}
#[test]
fn workspace_fill_carries_language_and_authored_choice_contract() {
let items = vec![Item {
num: 1,
kind: Kind::Deck,
title: "Institutions".to_string(),
requires: Vec::new(),
source: "notes.md".to_string(),
}];
let spec = crate::generate::GenerationSpec {
goal: "understand the institutions".to_string(),
language: Some("German".to_string()),
audience: Some("new voters".to_string()),
card_style: crate::config::GenerateCardStyle::AuthoredChoices,
};
let p = fill_prompt(&items, &spec);
assert!(p.contains("understand the institutions"));
assert!(p.contains("German"));
assert!(p.contains("new voters"));
assert!(p.contains("- [x]"));
assert!(p.contains("- [ ]"));
assert!(p.contains("[deck]"));
assert!(p.contains("[trace]"));
}
#[test]
fn workspace_authored_choices_reject_a_plain_deck_but_not_a_trace() {
let items = vec![
Item {
num: 1,
kind: Kind::Deck,
title: "Facts".to_string(),
requires: Vec::new(),
source: "notes.md".to_string(),
},
Item {
num: 2,
kind: Kind::Trace,
title: "Flow".to_string(),
requires: vec![1],
source: ".".to_string(),
},
];
let spec = GenerationSpec {
goal: "understand it".to_string(),
language: None,
audience: None,
card_style: crate::config::GenerateCardStyle::AuthoredChoices,
};
let filled = HashMap::from([
(1, "## Plain fact\nplain answer".to_string()),
(2, "## Trace checkpoint\nplain key point".to_string()),
]);
let error = validate_filled_card_styles(&items, &filled, &spec).unwrap_err();
assert!(format!("{error:#}").contains("workspace item 1"));
assert!(format!("{error:#}").contains("authored multiple-choice"));
}
#[test]
fn walk_prompt_predicts_shape_with_evidence() {
let mut spec = spec("understand the repo");
spec.language = Some("German".to_string());
spec.audience = Some("new contributors".to_string());
let p = walk_prompt(".", &spec, false, &TraceConfig::default());
assert!(p.contains("EXPLORE walk"));
assert!(p.contains("PREDICT its"));
assert!(p.contains("DOMAIN NOUNS"));
assert!(p.contains("SPINE"));
assert!(p.contains("CITE REAL STRUCTURAL EVIDENCE"));
assert!(p.contains("candidate traces"));
assert!(p.contains("<!-- at:"));
assert!(p.contains("Read, Glob"));
assert!(p.contains("German"));
assert!(p.contains("new contributors"));
assert!(!p.contains("WebFetch"));
}
#[test]
fn explore_prompts_include_only_nonempty_additional_instructions() {
for (label, prompt) in [
(
"workspace plan",
explore_prompt(
".",
&spec("understand the repo"),
false,
&TraceConfig {
extra: Some(" Focus on state transitions. ".to_string()),
..TraceConfig::default()
},
),
),
(
"walk",
walk_prompt(
".",
&spec("understand the repo"),
false,
&TraceConfig {
extra: Some(" Focus on state transitions. ".to_string()),
..TraceConfig::default()
},
),
),
] {
assert!(
prompt.contains("Additional instructions:"),
"{label}: {prompt}"
);
assert!(
prompt.contains("Focus on state transitions."),
"{label}: {prompt}"
);
}
for (label, prompt) in [
(
"workspace plan",
explore_prompt(
".",
&spec("understand the repo"),
false,
&TraceConfig {
extra: Some(" \n\t".to_string()),
..TraceConfig::default()
},
),
),
(
"walk",
walk_prompt(
".",
&spec("understand the repo"),
false,
&TraceConfig {
extra: Some(" \n\t".to_string()),
..TraceConfig::default()
},
),
),
] {
assert!(
!prompt.contains("Additional instructions:"),
"{label}: {prompt}"
);
}
}
#[cfg(unix)]
#[test]
fn walk_runs_the_backend_and_returns_checkpoints() {
use crate::testutil::{ask_config, exec_lock, fake_reply};
let _guard = exec_lock();
let dir = tempfile::tempdir().unwrap();
let cli = fake_reply(
dir.path(),
"## What happens next?\nThe parser builds a card.\n",
);
let checkpoints = walk(
"https://example.org/source",
&spec("understand the path"),
&TraceConfig::default(),
&ask_config(&cli),
)
.unwrap();
assert!(checkpoints.contains("What happens next?"));
assert!(checkpoints.contains("parser builds a card"));
}
const SAMPLE_PLAN: &str = "\
Goal understand X
Source a thing
Spine a -> b
1. [deck] The deck format: markers and directives
requires: none
@source: README.md
2. [trace] How text becomes Cards
requires: 1
@source: src/parser.rs
10. [trace] How a request is served
requires: 1, 2
@source: src/serve.rs
";
#[test]
fn parse_plan_extracts_items_kinds_requires_and_source() {
let items = parse_plan(SAMPLE_PLAN);
assert_eq!(3, items.len());
assert_eq!(Kind::Deck, items[0].kind);
assert_eq!(1, items[0].num);
assert!(items[0].title.starts_with("The deck format"));
assert!(items[0].requires.is_empty());
assert_eq!("README.md", items[0].source);
assert_eq!(Kind::Trace, items[1].kind);
assert_eq!(vec![1], items[1].requires);
assert_eq!(10, items[2].num);
assert_eq!(vec![1, 2], items[2].requires);
}
#[test]
fn materialize_writes_manifest_and_wired_stubs() {
let dir = std::env::temp_dir().join(format!("alix-explore-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let report = materialize(
SAMPLE_PLAN,
&dir,
"understand the repo",
None,
".",
None,
None,
)
.unwrap();
assert_eq!(2, report.traces);
assert_eq!(1, report.decks);
assert_eq!(0, report.filled);
let manifest = fs::read_to_string(dir.join("alix.toml")).unwrap();
assert!(manifest.contains("description = \"understand the repo\""));
assert!(!manifest.contains("title ="));
assert!(manifest.contains("source = "), "{manifest}");
assert!(!manifest.contains("origin"), "{manifest}");
let deck = fs::read_to_string(dir.join("decks/01-the-deck-format.md")).unwrap();
assert!(deck.contains("# The Deck Format"));
assert!(deck.contains("source: "));
let trace = fs::read_to_string(dir.join("decks/02-how-text-becomes-cards.md")).unwrap();
assert!(trace.contains("trace: \"How Text Becomes Cards\""));
assert!(trace.contains("- \"01-the-deck-format.md\""));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn materialize_writes_into_a_pre_existing_populated_dir_without_refusing() {
let dir = std::env::temp_dir().join(format!("alix-explore-ne-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("keep.txt"), "existing").unwrap();
materialize(SAMPLE_PLAN, &dir, "g", None, ".", None, None).unwrap();
assert_eq!(
"existing",
fs::read_to_string(dir.join("keep.txt")).unwrap()
);
assert!(dir.join("alix.toml").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn materialize_writes_title_and_description() {
let dir = std::env::temp_dir().join(format!("alix-explore-title-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
materialize(
SAMPLE_PLAN,
&dir,
"the goal",
Some("Repo Internals"),
".",
None,
None,
)
.unwrap();
let manifest = fs::read_to_string(dir.join("alix.toml")).unwrap();
assert!(manifest.contains("title = \"Repo Internals\""));
assert!(manifest.contains("description = \"the goal\""));
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn materialize_prefers_an_explicit_public_url_as_the_manifest_source() {
let dir = std::env::temp_dir().join(format!("alix-explore-src-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
materialize(
SAMPLE_PLAN,
&dir,
"the goal",
None,
".",
Some("https://example.org/source"),
None,
)
.unwrap();
let manifest = fs::read_to_string(dir.join("alix.toml")).unwrap();
assert!(
manifest.contains("source = \"https://example.org/source\""),
"{manifest}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn parse_filled_splits_on_item_delimiters() {
let raw = "\
preamble ignored
=== item 1 ===
## front a
back a
=== item 2 ===
# question
\tkey point
<!-- at: src/x.rs:1-3 -->
=== item 3 ===
";
let filled = parse_filled(raw);
assert_eq!(2, filled.len());
assert!(filled[&1].contains("## front a"));
assert!(filled[&2].contains("<!-- at: src/x.rs:1-3 -->"));
assert!(!filled.contains_key(&3));
}
#[test]
fn materialize_writes_filled_content_when_given() {
let dir = std::env::temp_dir().join(format!("alix-explore-fill-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
let mut filled = HashMap::new();
filled.insert(
2usize,
"## how does text become Cards?\nparse_str runs a state machine\n<!-- at: src/parser.rs:1-9 -->"
.to_string(),
);
let report = materialize(SAMPLE_PLAN, &dir, "g", None, ".", None, Some(&filled)).unwrap();
assert_eq!(1, report.filled);
let trace = fs::read_to_string(dir.join("decks/02-how-text-becomes-cards.md")).unwrap();
assert!(trace.contains("trace: \"How Text Becomes Cards\""));
assert!(trace.contains("## how does text become Cards?"));
assert!(trace.contains("<!-- at: src/parser.rs:1-9 -->"));
let deck = fs::read_to_string(dir.join("decks/01-the-deck-format.md")).unwrap();
assert!(deck.contains("Stub from `alix workspace generate`"));
let _ = fs::remove_dir_all(&dir);
}
fn merge_test_dirs(label: &str) -> (PathBuf, PathBuf) {
let root = std::env::temp_dir().join(format!("alix-merge-{label}-{}", std::process::id()));
let staging = root.join("staging");
let dest = root.join("dest");
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(&staging).unwrap();
(staging, dest)
}
#[test]
fn merge_rank_orders_assets_before_ordinary_entries_and_decks_last() {
for (name, expected) in [
(crate::assets::ROOT, 0),
("alix.toml", 1),
(crate::workspace::DECKS, 2),
] {
assert_eq!(
expected,
merge_rank(std::ffi::OsStr::new(name)),
"merge rank for {name}"
);
}
}
#[test]
fn only_a_decks_directory_uses_member_merge_semantics() {
let (staging, dest) = merge_test_dirs("decks-file");
fs::write(staging.join(crate::workspace::DECKS), "ordinary file\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(1, report.moved, "a file named decks moves as one entry");
assert!(report.conflicts.is_empty());
assert!(
dest.join(crate::workspace::DECKS).is_file(),
"the name alone must not turn a file into the member directory"
);
let _ = fs::remove_dir_all(staging.parent().unwrap());
let (staging, dest) = merge_test_dirs("ordinary-directory");
fs::create_dir_all(staging.join("notes")).unwrap();
fs::create_dir_all(dest.join("notes")).unwrap();
fs::write(staging.join("notes/fresh.txt"), "fresh\n").unwrap();
fs::write(dest.join("notes/keep.txt"), "keep\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(
0, report.moved,
"an ordinary directory collides as one entry"
);
assert_eq!(vec!["notes".to_string()], report.conflicts);
assert!(staging.join("notes/fresh.txt").is_file());
assert!(dest.join("notes/keep.txt").is_file());
assert!(!dest.join("notes/fresh.txt").exists());
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn a_forced_top_level_markdown_collision_counts_the_replacement() {
let (staging, dest) = merge_test_dirs("top-level-md");
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("topic.md"), "## old\nold answer\n").unwrap();
fs::write(staging.join("topic.md"), "## new\nnew answer\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, true, &mut store).unwrap();
assert_eq!(1, report.moved);
assert!(report.conflicts.is_empty());
assert!(
fs::read_to_string(dest.join("topic.md"))
.unwrap()
.contains("new answer")
);
assert!(!staging.join("topic.md").exists());
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn content_addressed_asset_collisions_deduplicate_equal_bytes_and_reject_unequal_bytes() {
for (label, staged_bytes, destination_bytes, equal) in [
("asset-equal", b"same".as_slice(), b"same".as_slice(), true),
(
"asset-unequal",
b"different".as_slice(),
b"original".as_slice(),
false,
),
] {
let (staging, dest) = merge_test_dirs(label);
let name = crate::assets::object_name(destination_bytes, "png");
let staged = staging.join(format!("assets/deck-deck1/{name}"));
let destination = dest.join(format!("assets/deck-deck1/{name}"));
fs::create_dir_all(staged.parent().unwrap()).unwrap();
fs::create_dir_all(destination.parent().unwrap()).unwrap();
fs::write(&staged, staged_bytes).unwrap();
fs::write(&destination, destination_bytes).unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let result = merge_built(&staging, &dest, false, &mut store);
if equal {
let report = result.unwrap();
assert_eq!(0, report.moved, "equal asset bytes are deduplicated");
assert!(!staged.exists(), "the redundant staged object is removed");
} else {
let error = match result {
Ok(_) => panic!("unequal bytes must refuse the asset collision"),
Err(error) => error,
};
assert!(
format!("{error:#}").contains("different bytes in the destination"),
"{error:#}"
);
assert_eq!(staged_bytes, fs::read(&staged).unwrap());
}
assert_eq!(destination_bytes, fs::read(&destination).unwrap());
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
}
#[test]
fn a_new_content_addressed_asset_counts_as_moved() {
let (staging, dest) = merge_test_dirs("nested-asset");
let name = crate::assets::object_name(b"image", "png");
let staged = staging.join(format!("assets/deck-deck1/{name}"));
fs::create_dir_all(staged.parent().unwrap()).unwrap();
fs::write(&staged, b"image").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(1, report.moved);
assert_eq!(
b"image",
fs::read(dest.join(format!("assets/deck-deck1/{name}")))
.unwrap()
.as_slice()
);
assert!(!staged.exists());
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[cfg(unix)]
#[test]
fn a_forced_merge_over_a_linked_directory_removes_the_link_not_its_target() {
let (staging, dest) = merge_test_dirs("linked-dir");
fs::create_dir_all(staging.join("notes")).unwrap();
fs::write(staging.join("notes/built.txt"), "built\n").unwrap();
let outside = dest.with_file_name("outside");
let _ = fs::remove_dir_all(&outside);
fs::create_dir_all(&outside).unwrap();
fs::write(outside.join("keep.txt"), "keep\n").unwrap();
fs::create_dir_all(&dest).unwrap();
std::os::unix::fs::symlink(&outside, dest.join("notes")).unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
merge_built(&staging, &dest, true, &mut store).unwrap();
assert_eq!(
"keep\n",
fs::read_to_string(outside.join("keep.txt")).unwrap(),
"publishing into a linked name must not reach the link's target"
);
assert_eq!(
"built\n",
fs::read_to_string(dest.join("notes/built.txt")).unwrap(),
"the built directory takes the name the link held"
);
}
#[test]
fn a_clean_merge_moves_every_entry_and_reports_zero_conflicts() {
let (staging, dest) = merge_test_dirs("clean");
fs::write(staging.join("alix.toml"), "[defaults]\n").unwrap();
fs::write(staging.join("01-a.txt"), "deck a\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(2, report.moved);
assert!(report.conflicts.is_empty());
assert_eq!(
"[defaults]\n",
fs::read_to_string(dest.join("alix.toml")).unwrap()
);
assert_eq!(
"deck a\n",
fs::read_to_string(dest.join("01-a.txt")).unwrap()
);
assert_eq!(0, fs::read_dir(&staging).unwrap().count());
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn a_name_collision_keeps_the_original_content_and_leaves_the_new_file_in_staging() {
let (staging, dest) = merge_test_dirs("collide");
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("01-a.txt"), "the user's own deck\n").unwrap();
fs::write(staging.join("01-a.txt"), "the freshly generated deck\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(0, report.moved);
assert_eq!(vec!["01-a.txt".to_string()], report.conflicts);
assert_eq!(
"the user's own deck\n",
fs::read_to_string(dest.join("01-a.txt")).unwrap()
);
assert_eq!(
"the freshly generated deck\n",
fs::read_to_string(staging.join("01-a.txt")).unwrap()
);
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn force_replaces_a_colliding_file_with_the_new_version() {
let (staging, dest) = merge_test_dirs("force");
fs::create_dir_all(&dest).unwrap();
fs::write(dest.join("01-a.txt"), "stale\n").unwrap();
fs::write(staging.join("01-a.txt"), "fresh\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, true, &mut store).unwrap();
assert_eq!(1, report.moved);
assert!(report.conflicts.is_empty());
assert_eq!(
"fresh\n",
fs::read_to_string(dest.join("01-a.txt")).unwrap()
);
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn a_forced_md_collision_routes_through_the_replace_protocol() {
let (staging, dest) = merge_test_dirs("md-replace");
fs::create_dir_all(dest.join("decks")).unwrap();
fs::create_dir_all(staging.join("decks")).unwrap();
fs::write(
dest.join("decks/01-a.md"),
"---\nformat-version: 1\nid: \"deck-da1\"\n---\n## old\nold\n<!-- id: card-c1 -->\n",
)
.unwrap();
fs::write(staging.join("decks/01-a.md"), "## new q\nnew ans\n").unwrap();
let mut store = crate::state::open_store(&dest.join("decks/01-a.md"), &dest).unwrap();
store.get_or_insert("card-c1");
store.save().unwrap();
let report = merge_built(&staging, &dest, true, &mut store).unwrap();
assert_eq!(1, report.moved);
assert!(store.get("card-c1").is_none());
assert!(dest.join("decks/01-a.md.bak").exists());
assert!(
fs::read_to_string(dest.join("decks/01-a.md"))
.unwrap()
.contains("new q")
);
assert!(!staging.join("decks/01-a.md").exists());
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn workspace_assets_merge_per_entry_without_replacing_the_directory() {
let (staging, dest) = merge_test_dirs("dir");
fs::create_dir_all(staging.join("assets")).unwrap();
fs::write(staging.join("assets/img.svg"), "new\n").unwrap();
let mut store = Store::open(dest.join("deck1.json")).unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(1, report.moved);
assert!(report.conflicts.is_empty());
assert_eq!(
"new\n",
fs::read_to_string(dest.join("assets/img.svg")).unwrap()
);
fs::create_dir_all(staging.join("assets")).unwrap();
fs::write(staging.join("assets/img.svg"), "newer\n").unwrap();
let report = merge_built(&staging, &dest, false, &mut store).unwrap();
assert_eq!(0, report.moved);
assert_eq!(vec!["assets/img.svg".to_string()], report.conflicts);
assert_eq!(
"new\n",
fs::read_to_string(dest.join("assets/img.svg")).unwrap()
);
let _ = fs::remove_dir_all(staging.parent().unwrap());
}
#[test]
fn freeze_workspace_owns_content_addressed_trace_and_fact_excerpts() {
let dir = std::env::temp_dir().join(format!("alix-snap-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("src")).unwrap();
fs::create_dir_all(dir.join("decks")).unwrap();
fs::write(dir.join("src/a.rs"), "x\ny\nz\n").unwrap();
fs::write(
dir.join("alix.toml"),
"source = \"https://example.org/current\"\n",
)
.unwrap();
let src = dir.join("src");
fs::write(
dir.join("decks/01-t.md"),
format!(
"---\nformat-version: 1\nid: \"deck-trace\"\ntrace: t\nsource: {}\n---\n## h\np\n<!-- at: a.rs:1-2 -->\n",
src.display()
),
)
.unwrap();
fs::write(
dir.join("decks/02-d.md"),
format!(
"---\nformat-version: 1\nid: \"deck-facts\"\nsource: {}\n---\n## q\na\n<!-- at: a.rs:3 -->\n",
src.display()
),
)
.unwrap();
fs::write(dir.join("decks/03-plain.md"), "# d\n## q\na\n").unwrap();
let summary = freeze_workspace(&dir).unwrap();
assert_eq!((2, 2), (summary.decks, summary.files));
assert!(summary.failed.is_empty(), "{:?}", summary.failed);
let trace_name = crate::assets::object_name(b"x\ny\n", "rs");
let fact_name = crate::assets::object_name(b"z\n", "rs");
assert!(
dir.join(format!("assets/deck-trace/{trace_name}"))
.is_file()
);
assert!(dir.join(format!("assets/deck-facts/{fact_name}")).is_file());
assert!(!dir.join("assets/a.rs").exists());
let fact = fs::read_to_string(dir.join("decks/02-d.md")).unwrap();
assert!(
fact.contains(&format!("source: {}", src.display())),
"the live source declaration stays untouched: {fact}"
);
assert!(!fact.contains("source: \"assets/"), "{fact}");
assert!(!fact.contains("origin:"), "freezing stamps nothing: {fact}");
assert!(!fact.contains("<!-- at: a.rs:3 -->"), "{fact}");
assert!(
fact.contains("<!-- at: a.rs:3 fingerprint: xxh64-")
&& fact.contains(&format!(" asset: {fact_name} -->")),
"{fact}"
);
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn freeze_workspace_surfaces_a_deck_whose_source_cannot_be_frozen() {
let dir = std::env::temp_dir().join(format!("alix-snap-fail-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(dir.join("decks")).unwrap();
fs::write(dir.join("alix.toml"), "[defaults]\n").unwrap();
fs::write(
dir.join("decks/01-broken.md"),
format!(
"---\nformat-version: 1\nid: \"deck-broken\"\nsource: {}/does-not-exist\n---\n## q\na\n<!-- at: src/x.rs:1 -->\n",
dir.display()
),
)
.unwrap();
let summary = freeze_workspace(&dir).unwrap();
assert_eq!(0, summary.decks);
assert_eq!(1, summary.failed.len(), "{:?}", summary.failed);
assert!(
summary.failed[0].contains("01-broken.md"),
"{:?}",
summary.failed
);
assert!(!dir.join("assets").exists());
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn only_initialized_source_less_decks_with_local_images_are_frozen() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("decks")).unwrap();
fs::write(dir.path().join("alix.toml"), "").unwrap();
fs::write(dir.path().join("local.png"), b"local image").unwrap();
fs::write(
dir.path().join("decks/local.md"),
"---\nformat-version: 1\nid: \"deck-deck1\"\n---\n## q\n\na\n",
)
.unwrap();
fs::write(
dir.path().join("decks/remote.md"),
"---\nformat-version: 1\nid: \"deck-deck2\"\n---\n## q\n\na\n",
)
.unwrap();
fs::write(
dir.path().join("decks/plain.md"),
"---\nformat-version: 1\nid: \"deck-deck3\"\n---\n## q\na\n",
)
.unwrap();
for deck_id in ["deck-deck2", "deck-deck3"] {
let owned = dir.path().join(format!("assets/{deck_id}"));
fs::create_dir_all(&owned).unwrap();
fs::write(owned.join("not-content-addressed"), b"invalid").unwrap();
}
let summary = freeze_workspace(dir.path()).unwrap();
assert_eq!((1, 1), (summary.decks, summary.files));
assert!(summary.failed.is_empty(), "{:?}", summary.failed);
let image_name = crate::assets::object_name(b"local image", "png");
assert!(
dir.path()
.join(format!("assets/deck-deck1/{image_name}"))
.is_file(),
"the local image makes its source-less deck eligible"
);
}
#[test]
fn freeze_workspace_sums_evidence_and_images_from_one_member() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(dir.path().join("decks")).unwrap();
fs::write(dir.path().join("alix.toml"), "").unwrap();
let source = dir.path().join("notes.md");
fs::write(&source, "one\ntwo\n").unwrap();
fs::write(dir.path().join("diagram.png"), b"diagram").unwrap();
fs::write(
dir.path().join("decks/both.md"),
format!(
"---\nformat-version: 1\nid: \"deck-deck4\"\nsource: {}\n---\n## q\n\na\n<!-- at: notes.md:2 -->\n",
crate::parser::yaml_quote(&source.display().to_string())
),
)
.unwrap();
let summary = freeze_workspace(dir.path()).unwrap();
assert_eq!((1, 2), (summary.decks, summary.files));
assert!(summary.failed.is_empty(), "{:?}", summary.failed);
assert_eq!(
2,
fs::read_dir(dir.path().join("assets/deck-deck4"))
.unwrap()
.count(),
"one evidence object plus one image object"
);
}
#[test]
fn rewrite_scope_discards_empty_components_with_and_without_a_root() {
assert_eq!(
vec!["alpha".to_string(), "beta".to_string()],
rewrite_scope("alpha + + beta", None),
"a rootless scope keeps only its nonempty components in order"
);
let root = tempfile::tempdir().unwrap();
fs::write(root.path().join("alpha"), "a\n").unwrap();
fs::write(root.path().join("beta"), "b\n").unwrap();
assert_eq!(
vec![
root.path().join("alpha").display().to_string(),
root.path().join("beta").display().to_string(),
],
rewrite_scope("alpha + + beta", Some(root.path())),
"a rooted scope keeps only its nonempty components in order"
);
}
#[test]
fn rewrite_scope_anchors_a_repo_relative_scope_without_doubling() {
fn slashed(paths: Vec<String>) -> Vec<String> {
paths
.into_iter()
.map(|path| path.replace('\\', "/"))
.collect()
}
let root = std::env::temp_dir().join(format!("alix-scope-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let crate_src = root.join("crates/mycrate/src");
fs::create_dir_all(&crate_src).unwrap();
fs::write(crate_src.join("lib.rs"), "fn main() {}\n").unwrap();
let lib = crate_src.join("lib.rs").display().to_string();
let source = root.join("crates/mycrate");
assert_eq!(
slashed(vec![lib.clone()]),
slashed(rewrite_scope("crates/mycrate/src/lib.rs", Some(&source)))
);
assert_eq!(
slashed(vec![lib.clone()]),
slashed(rewrite_scope("src/lib.rs", Some(&source)))
);
assert_eq!(
slashed(vec![source.display().to_string()]),
slashed(rewrite_scope(".", Some(&source)))
);
assert_eq!(
slashed(vec![lib.clone()]),
slashed(rewrite_scope(&lib, Some(&source)))
);
fs::write(crate_src.join("other.rs"), "x\n").unwrap();
assert_eq!(
slashed(vec![
lib.clone(),
crate_src.join("other.rs").display().to_string()
]),
slashed(rewrite_scope(
"crates/mycrate/src/lib.rs + other.rs",
Some(&source)
))
);
let _ = fs::remove_dir_all(&root);
}
}