use headwater_adapter::{Format, Subject};
use headwater_census::census;
use headwater_census::shelves::Taxonomy;
use headwater_census::walk::Corpus;
use headwater_check::{rules_digest, Cache, Context, Date, Declared, Register, Shape};
use headwater_cli::{JsonWord, ProbeWord, SweepWord, TaxonomyWord, Verb};
use headwater_graph::anchors::Resolvers;
use headwater_graph::declarations::Declarations;
use headwater_graph::{Config, Graph};
use headwater_query::mcp::Written;
use headwater_query::{Budget, Surface};
use headwater_resolve::render_errors;
use headwater_scaffold::reading::Surface as EntryPoint;
use std::path::{Path, PathBuf};
use std::process::ExitCode;
fn main() -> ExitCode {
if headwater_cli::paint::wants_root_help() {
print!(
"{}",
headwater_cli::paint::banner(
headwater_resolve::release::ENGINE,
headwater_cli::paint::stdout_color()
)
);
}
let cli = match headwater_cli::parsed() {
Ok(cli) => cli,
Err(error) => match error.use_stderr() {
true => return fail(&headwater_cli::headline(&error)),
false => {
let _ = error.print();
return ExitCode::SUCCESS;
}
},
};
if cli.version {
println!("{}", headwater_resolve::release::ENGINE);
return ExitCode::SUCCESS;
}
let root = match cli.root {
Some(path) => path,
None => match std::env::current_dir() {
Ok(path) => path,
Err(error) => return refuse(&format!("no working directory: {error}")),
},
};
let Some(verb) = cli.verb else {
return fail("no verb. Try `headwater check`");
};
dispatch(&root, verb)
}
fn dispatch(root: &Path, verb: Verb) -> ExitCode {
match verb {
Verb::Check {
strict,
fix,
no_cache,
now,
change,
read_set,
register,
format,
json,
} => check(
root,
Asked {
strict,
cached: !no_cache,
fixing: fix,
now,
read_set,
register_out: register,
format: chosen(json, format),
change,
},
),
Verb::Change { base, out } => match (base, out) {
(Some(base), Some(out)) => change(root, &base, &out),
_ => fail(
"`change` takes a base revision and a directory to write into. Try `headwater \
change HEAD .headwater/change` before `headwater check --change \
.headwater/change/manifest`",
),
},
Verb::Gate {
read_set,
now,
json,
} => gate(root, read_set, now, json),
Verb::Derived {} => derived(root),
Verb::MergeDriver {
ancestor,
current,
other,
path,
} => match (ancestor, current, other, path) {
(Some(_), Some(current), Some(_), Some(path)) => merge_driver(Path::new(¤t), &path),
_ => fail(
"`merge-driver` takes the four operands git hands a merge driver. Git runs it as \
`headwater merge-driver %O %A %B %P`, and `headwater init --git` prints the \
`git config` line that says so",
),
},
Verb::Route { task, budget, json } => match task.is_empty() {
true => fail("`route` takes a task description. Try `headwater route \"add rate limiting to the ingest API\"`"),
false => route(root, &task.join(" "), budget, json),
},
Verb::Neighbors {
task,
model,
top,
json,
} => match task.is_empty() {
true => fail("`neighbors` takes a task description. Try `headwater neighbors \"add rate limiting to the ingest API\"`"),
false => neighbors(root, &task.join(" "), model, top, json),
},
Verb::Explain { target, json } => match target {
None => fail("`explain` takes a path or an identifier"),
Some(target) => explain(root, &target, json),
},
Verb::Mcp { now, write } => mcp(root, now, write),
Verb::New {
kind,
title,
summary,
relates,
facet,
now,
} => match kind {
None => fail(
"`new` takes a kind. Try `headwater new decision --title \"Adopt an overlay\"`",
),
Some(kind) => new(root, &kind, title, summary, &relates, &facet, now),
},
Verb::Capture { format, json } => capture(root, chosen(json, format)),
Verb::Sweep { word } => match word {
None => fail(&format!(
"`sweep` takes a second word: {}",
headwater_verbs::words_of("sweep")
)),
Some(SweepWord::Plan { under }) => sweep_plan(root, under),
Some(SweepWord::Report { path, format, json }) => match path {
None => fail(
"`sweep report` takes the path of the file an agent wrote back. \
`headwater sweep plan` prints the shape of it",
),
Some(path) => sweep_report(root, Path::new(&path), chosen(json, format)),
},
Some(SweepWord::Other(words)) => no_such_second_word("sweep", &words),
},
Verb::Probe { word } => match word {
None => fail(&format!(
"`probe` takes a second word: {}",
headwater_verbs::words_of("probe")
)),
Some(ProbeWord::Plan {
tier,
arm,
category,
seed,
}) => probe_plan(
root,
tier.as_deref(),
arm.as_deref(),
category.as_deref(),
seed,
),
Some(ProbeWord::Record { path }) => match path {
None => fail(
"`probe record` takes the path of a transcript a recorder wrote. \
`headwater probe plan` prints the shape of it",
),
Some(path) => probe_record(root, Path::new(&path)),
},
Some(ProbeWord::Grade { path }) => match path {
None => fail(
"`probe grade` takes the path of a transcript a recorder wrote. It grades that \
transcript against the probes this corpus declares",
),
Some(path) => probe_grade(root, Path::new(&path)),
},
Some(ProbeWord::Stale) => probe_stale(root),
Some(ProbeWord::Other(words)) => no_such_second_word("probe", &words),
},
Verb::Generate { check } => generate(root, check),
Verb::Import {
name,
expect,
write,
} => import(root, name.as_deref(), expect.as_deref(), write),
Verb::Export {
profile,
format,
at,
check,
json,
} => export(root, profile, chosen(json, format), typed(json), at, check),
Verb::Init {
corpus,
package,
git,
git_config,
} => init(root, corpus, package, git, git_config),
Verb::Infer {
owner,
until,
write,
now,
} => infer(root, owner, until, write, now),
Verb::Conformance { level, now, json } => {
conformance(root, level.as_deref(), now, json)
}
Verb::Query { .. } => refuse(
"`query <expression>` is listed in spec 6 and no document states what an expression \
is, so this engine implements none. See `docs/spec/13-open-obligations.md`. \
`headwater route` and `headwater explain` are the reads that exist",
),
Verb::Json { word } => match word {
None => fail(&format!(
"`json` takes a second word: {}",
headwater_verbs::words_of("json")
)),
Some(JsonWord::Field { path }) => match path.is_empty() {
true => fail(
"`json field` takes the path of steps to a member. Try \
`headwater json field tool_input file_path`",
),
false => json_field(&path),
},
Some(JsonWord::Count { path }) => json_count(&path),
Some(JsonWord::Quote) => json_quote(),
Some(JsonWord::Other(words)) => no_such_second_word("json", &words),
},
Verb::Help { verb } => print_help_for(&verb),
Verb::Completions { shell } => completions(shell),
Verb::Taxonomy { word } => match word {
None => fail(&format!(
"`taxonomy` takes a second word: {}",
headwater_verbs::words_of("taxonomy")
)),
Some(TaxonomyWord::Validate) => validate(root),
Some(TaxonomyWord::Resolve { check }) => resolve(root, check),
Some(TaxonomyWord::Audit { now, record }) => audit(root, now, record),
Some(TaxonomyWord::Publish {
package,
from,
assembly,
out,
clear_killed,
json,
}) => {
publish(
root,
package.as_deref(),
from.as_deref(),
assembly.as_deref(),
out.as_deref(),
clear_killed,
json,
)
}
Some(TaxonomyWord::Vendor { path, expect }) => match path {
None => fail(
"`taxonomy vendor` takes the path of an artifact somebody already fetched, \
or the https:// location of a published artifact zip",
),
Some(path) => vendor(root, &path, expect.as_deref()),
},
Some(TaxonomyWord::Diff { path, to, now }) => match path {
None => fail(
"`taxonomy diff` takes the path of a published artifact somebody already \
fetched. This verb opens no socket, so it compares against a directory it \
is handed, and `--to <version>` states which version that directory is \
expected to be",
),
Some(path) => diff(root, Path::new(&path), to.as_deref(), now),
},
Some(TaxonomyWord::Migrate {
path,
to,
apply,
now,
}) => match path {
None => fail(
"`taxonomy migrate` takes the path of a published artifact somebody already \
fetched. This verb opens no socket, so it applies a payload it is handed, \
and `--to <version>` states which version that directory is expected to be. \
Without `--apply` it reports what it would write and writes nothing",
),
Some(path) => migrate(root, Path::new(&path), to.as_deref(), now, apply),
},
Some(TaxonomyWord::Graph { view, legend }) => taxonomy_graph(root, view, legend),
Some(TaxonomyWord::Other(words)) => fail(&format!(
"`taxonomy {}` is not a verb this binary carries yet. It carries {}",
words.first().map(String::as_str).unwrap_or_default(),
headwater_verbs::words_of("taxonomy")
)),
},
Verb::Other(words) => fail(&format!(
"`{}` is not a verb this binary carries yet. It carries {}",
words.first().map(String::as_str).unwrap_or_default(),
headwater_verbs::listed()
)),
}
}
fn chosen(json: bool, format: Option<String>) -> Option<String> {
match json {
true => Some("json".to_string()),
false => format,
}
}
const fn typed(json: bool) -> &'static str {
match json {
true => "--json",
false => "--format",
}
}
fn print_help_for(words: &[String]) -> ExitCode {
let mut command = headwater_cli::command();
command.build();
let mut cursor = &command;
for (at, word) in words.iter().enumerate() {
let Some(next) = cursor.find_subcommand(word.as_str()) else {
if at == 0 {
return fail(&format!(
"`{word}` is not a verb this binary carries yet. It carries {}",
headwater_verbs::listed()
));
}
let verb = words[at - 1].as_str();
return match headwater_verbs::words_of(verb).is_empty() {
true => fail(&format!("`{verb}` takes no second word")),
false => no_such_second_word(verb, &words[at..]),
};
};
cursor = next;
}
if words.is_empty() {
print!(
"{}",
headwater_cli::paint::banner(
headwater_resolve::release::ENGINE,
headwater_cli::paint::stdout_color()
)
);
}
let target = descend(&mut command, words).expect("the walk above found every word");
let _ = target.print_help();
ExitCode::SUCCESS
}
fn completions(shell: Option<headwater_cli::Shell>) -> ExitCode {
let Some(shell) = shell else {
let named: Vec<&str> = headwater_cli::Shell::ALL
.iter()
.map(|one| one.typed())
.collect();
return fail(&format!(
"`completions <shell>` writes a completion script on standard output, for one of {}. \
Where the script goes is the shell's own convention rather than this engine's, so \
redirect it there: `{} completions bash > f && . f` loads one into the shell in \
front of you",
listed(&named),
headwater_verbs::BINARY
));
};
let mut command = headwater_cli::paint::flattened(headwater_cli::command_in(
headwater_cli::paint::WIDTH,
headwater_cli::paint::ColorMode::Plain,
));
clap_complete::generate(
clap_complete::Shell::from(shell),
&mut command,
headwater_verbs::BINARY,
&mut std::io::stdout(),
);
ExitCode::SUCCESS
}
fn listed(words: &[&str]) -> String {
let quoted: Vec<String> = words.iter().map(|word| format!("`{word}`")).collect();
match quoted.split_last() {
None => String::new(),
Some((last, [])) => last.clone(),
Some((last, rest)) => format!("{} and {last}", rest.join(", ")),
}
}
fn stdin_text() -> Option<String> {
use std::io::Read;
let mut text = String::new();
std::io::stdin().read_to_string(&mut text).ok()?;
Some(text)
}
fn json_field(path: &[String]) -> ExitCode {
let Some(text) = stdin_text() else {
return refuse("standard input is not text, so no JSON object was read from it");
};
match headwater_yaml::json::field(&text, path) {
Some(value) => {
println!("{value}");
ExitCode::SUCCESS
}
None => refuse(&format!(
"`{}` reaches no scalar of the object on standard input",
path.join(".")
)),
}
}
fn json_count(path: &[String]) -> ExitCode {
let Some(text) = stdin_text() else {
return refuse("standard input is not text, so no JSON object was read from it");
};
match headwater_yaml::json::count(&text, path) {
Some(count) => {
println!("{count}");
ExitCode::SUCCESS
}
None => refuse(&format!(
"`{}` reaches no array and no object of the object on standard input",
match path.is_empty() {
true => ".".to_string(),
false => path.join("."),
}
)),
}
}
fn json_quote() -> ExitCode {
let Some(text) = stdin_text() else {
return refuse("standard input is not text, so nothing was quoted");
};
print!("{}", headwater_yaml::json::Json::string(text).render());
ExitCode::SUCCESS
}
fn descend<'a>(command: &'a mut clap::Command, words: &[String]) -> Option<&'a mut clap::Command> {
match words.split_first() {
None => Some(command),
Some((word, rest)) => descend(command.find_subcommand_mut(word.as_str())?, rest),
}
}
fn no_such_second_word(verb: &str, words: &[String]) -> ExitCode {
fail(&format!(
"`{verb} {}` is not a verb this binary carries. It carries {}",
words.first().map(String::as_str).unwrap_or_default(),
headwater_verbs::words_of(verb)
))
}
fn validate(root: &Path) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let repository = match headwater_resolve::repository(root) {
Ok(repository) => repository,
Err(errors) => {
eprintln!("headwater: {}", err("the taxonomy did not resolve"));
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
println!(
"{}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Heading,
"sources, in application order",
mode
)
);
for source in &repository.resolution.sources {
println!(
" {}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Path, source, mode)
);
}
println!();
print!("{}", repository.resolution.foundings());
println!();
print!(
"{}",
headwater_resolve::rules::display_names(&repository.resolution.taxonomy, mode)
);
let findings = repository.resolution.validate();
println!(
"\n{}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Heading, "rules", mode)
);
print!("{}", headwater_resolve::rules::render(mode));
let (unmatched, notice) = match Declarations::read(&repository.resolution.taxonomy) {
Ok(declarations) => {
let consumer = &repository.consumer;
let corpus = Corpus::declared(root, &consumer.corpus_root, &consumer.exclusions);
let resolvers = headwater_graph::anchors::Resolvers::over(&corpus);
let scope = headwater_graph::scope::Scope::declared(&declarations);
let ignored = headwater_graph::scope::Ignored::read(&corpus.base);
let own = headwater_resolve::package::manifest_at(&corpus.base)
.map(|manifest| headwater_resolve::package::content_roots(&manifest))
.unwrap_or_default();
let beside = headwater_graph::scope::tree_directories(
&corpus.base,
&repository.resolution.sources,
&own,
&ignored,
);
if scope.tree_is_absent(&resolvers, &beside) {
let count = scope.members.len();
let noun = if count == 1 { "pattern" } else { "patterns" };
(
Vec::new(),
Some(format!(
"governed scope: no tree beside this taxonomy, so the coverage count of \
its {count} {noun} is skipped"
)),
)
} else {
(scope.ignoring(ignored).unmatched(&resolvers), None)
}
}
Err(_) => (Vec::new(), None),
};
if let Some(notice) = notice {
println!("\n{notice}");
}
if findings.is_empty() && unmatched.is_empty() {
println!("\n{} is valid", repository.consumer.package);
return ExitCode::SUCCESS;
}
println!("\n{} is not valid", repository.consumer.package);
if !findings.is_empty() {
eprint!("{}", indent(&err(&render_errors(&findings))));
}
for (pattern, why) in &unmatched {
eprintln!(
" {}",
err(&format!(
"governed scope pattern `{pattern}` matches no entry of the tree: {why}"
))
);
}
advise(root, &repository.consumer);
ExitCode::FAILURE
}
fn advise(root: &Path, consumer: &headwater_resolve::Consumer) {
if let Some(advice) = headwater_resolve::selection::advice(root, consumer) {
eprint!("{}", indent(&advice.render()));
}
}
fn advise_recipe(root: &Path, package: Option<&str>, from: Option<&Path>, assembly: Option<&str>) {
let Some(assembly) = assembly else {
return;
};
let directory = match from {
Some(directory) => directory.to_path_buf(),
None => {
let name = match package {
Some(name) => name.to_string(),
None => match headwater_resolve::package::consumer(root) {
Ok(consumer) => consumer.package,
Err(_) => return,
},
};
match headwater_resolve::package::located(root, &name) {
Some((directory, _)) => directory,
None => return,
}
}
};
if let Some(advice) = headwater_resolve::selection::for_recipe(root, &directory, assembly) {
eprint!("{}", indent(&advice.render()));
}
}
fn resolve(root: &Path, check_only: bool) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let repository = match headwater_resolve::repository(root) {
Ok(repository) => repository,
Err(errors) => {
eprintln!(
"headwater: {}",
err("the taxonomy did not resolve, so no lock is possible")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
if !repository.resolution.founded.is_empty() {
eprint!("{}", repository.resolution.foundings());
}
let sources = match headwater_resolve::package::sources(root, &repository.consumer) {
Ok(sources) => sources,
Err(errors) => {
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let authored = headwater_lock::authored_at(root);
let text = match headwater_lock::write(
&repository.consumer.package,
&repository.consumer.version,
&sources,
&repository.resolution,
authored.payload(),
) {
Ok(text) => text,
Err(findings) => {
eprintln!(
"headwater: {}",
err(
"the taxonomy does not validate, so no lock is written. A lock is a \
validated taxonomy or it is nothing"
)
);
eprint!("{}", indent(&err(&render_errors(&findings))));
advise(root, &repository.consumer);
return ExitCode::FAILURE;
}
};
let path = root.join(headwater_lock::LOCK);
if check_only {
let committed = std::fs::read_to_string(&path).unwrap_or_default();
return match headwater_lock::diverged(&committed, &text) {
headwater_lock::Divergence::Same => {
println!(
"{} is what the sources resolve to",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Path,
headwater_lock::LOCK,
mode
)
);
ExitCode::SUCCESS
}
headwater_lock::Divergence::Form { adoption } => {
eprintln!(
"headwater: {}",
err(&format!(
"{} carries the taxonomy its sources resolve to, and is not written \
in the form `headwater taxonomy resolve` writes it. Nothing about \
your sources changed",
headwater_lock::LOCK
))
);
match adoption {
true => eprintln!(
" {}",
err(
"The `adoption` block is where the two differ. That block is \
authored, and this file's header invites a person to edit it, so \
what moved is its form and not the debt it declares. Run \
`headwater taxonomy resolve`: every task, owner, expiry and pair \
is carried through"
)
),
false => eprintln!(
" {}",
err(
"The difference is not inside the `adoption` block. Run `headwater \
taxonomy resolve` and commit the result"
)
),
}
ExitCode::FAILURE
}
headwater_lock::Divergence::Generated => {
eprintln!(
"headwater: {}",
err(&format!(
"{} is not what the sources resolve to. Run `headwater taxonomy \
resolve` and commit the result",
headwater_lock::LOCK
))
);
if let Ok(lock) = headwater_lock::read(&committed) {
for moved in lock.moved(root) {
eprintln!(" {moved} has changed since the lock was written");
}
}
ExitCode::FAILURE
}
headwater_lock::Divergence::Unreadable(why) => {
eprintln!(
"headwater: {}",
err(&format!("{} did not read: {why}", headwater_lock::LOCK))
);
match &authored {
headwater_lock::Authored::Opaque { .. } => eprintln!(
" Nothing can be seen of its adoption block, so `headwater taxonomy \
resolve` refuses this file rather than replacing it. Repair the file, \
or delete it to resolve from the sources alone and write the block again"
),
_ => eprintln!(
" Nothing here can say whether a source moved, because the file that \
records them will not read. Run `headwater taxonomy resolve` and commit \
the result. The digest covers the resolution and has never covered the \
adoption block, so that block is carried through"
),
}
ExitCode::FAILURE
}
};
}
let note = match &authored {
headwater_lock::Authored::NoLock => {
"no lock was there, so there was no adoption block to carry".to_string()
}
headwater_lock::Authored::Nothing => {
"the lock that was there declared no adoption block".to_string()
}
headwater_lock::Authored::Payload(payload) => carried(payload),
headwater_lock::Authored::Salvaged { why, payload } => format!(
"{}, out of a lock that did not read. The digest covers the resolution \
and has never covered the adoption block\n the lock said: {why}",
carried(payload)
),
headwater_lock::Authored::NothingBehind { why } => format!(
"the lock that was there declared no adoption block, and it did not read \
either\n the lock said: {why}"
),
headwater_lock::Authored::Opaque { why } => {
eprintln!(
"headwater: {}",
err(&format!("{} did not read: {why}", headwater_lock::LOCK))
);
eprintln!(
" {}",
err(
"Nothing can be seen of its adoption block, which is the one authored part \
of the file, so this run will not replace it. Repair the file, or delete it \
to resolve from the sources alone and write the block again"
)
);
return ExitCode::FAILURE;
}
};
if let Some(parent) = path.parent() {
if let Err(error) = std::fs::create_dir_all(parent) {
return refuse(&format!("cannot create {}: {error}", parent.display()));
}
}
if let Err(error) = std::fs::write(&path, &text) {
return refuse(&format!("cannot write {}: {error}", path.display()));
}
println!(
"wrote {}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Path, headwater_lock::LOCK, mode)
);
for source in &repository.resolution.sources {
println!(
" from {}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Path, source, mode)
);
}
println!(" {note}");
ExitCode::SUCCESS
}
fn carried(payload: &headwater_yaml::Mapping) -> String {
let tasks = payload
.get("tasks")
.and_then(|node| node.value.as_seq())
.map(|items| items.len())
.unwrap_or_default();
format!(
"carried the adoption block through, {tasks} task{}",
if tasks == 1 { "" } else { "s" }
)
}
fn taxonomy_graph(
root: &Path,
view: headwater_cli::taxonomy_graph::View,
legend: bool,
) -> ExitCode {
let lock = match headwater_lock::at(root) {
Ok(lock) => lock,
Err(error) => {
eprintln!("headwater: {}", err(&format!("{error}")));
return ExitCode::FAILURE;
}
};
print!(
"{}",
headwater_cli::taxonomy_graph::render(
&lock.package,
&lock.version,
&lock.taxonomy,
view,
legend
)
);
ExitCode::SUCCESS
}
fn audit(root: &Path, now: Option<Date>, record: bool) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let Some(context) = now.map(Context::at).or_else(Context::from_system_clock) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let reading = headwater_audit::reading::Reading::of(
&run_of(&loaded, &context).adoption,
&loaded.bound.digest,
context.now(),
);
if record {
match headwater_audit::reading::append(root, &reading) {
Err(error) => {
return refuse(&format!(
"the adoption reading did not append to {}: {error}",
headwater_audit::reading::STORE
))
}
Ok(headwater_audit::reading::Appended::Held) => eprintln!(
"headwater: {} already holds a reading at {} under {}, and nothing was appended",
headwater_audit::reading::STORE,
context.now(),
loaded.bound.digest
),
Ok(headwater_audit::reading::Appended::Written) => eprintln!(
"headwater: appended one adoption reading to {}",
headwater_audit::reading::STORE
),
}
}
let (recorded, unreadable) = match headwater_audit::reading::load(root) {
Ok(held) => held,
Err(error) => {
return refuse(&format!(
"{} did not read: {error}",
headwater_audit::reading::STORE
))
}
};
let mut graph = loaded.graph;
if !graph.scope.is_empty() {
let ignored = headwater_graph::scope::Ignored::read(root);
for reach in &mut graph.scope {
reach.retain_unignored(&ignored);
}
}
let audit = headwater_audit::take(
headwater_audit::Subject {
package: loaded.bound.package.clone(),
version: loaded.bound.version.clone(),
lock: loaded.bound.digest.clone(),
now: context.now(),
},
&loaded.census,
&graph,
&loaded.taxonomy,
&loaded.shape,
&loaded.relations,
headwater_audit::Series {
reading,
recorded,
unreadable,
},
);
print!("{}", audit.render(headwater_cli::paint::stdout_color()));
ExitCode::SUCCESS
}
fn conformance(root: &Path, level: Option<&str>, now: Option<Date>, json: bool) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let Some(context) = now.map(Context::at).or_else(Context::from_system_clock) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let set = match headwater_conformance::at(root, &loaded.consumer) {
Ok(set) => set,
Err(error) => return refuse(&error.to_string()),
};
let waivers = match headwater_conformance::waivers(root) {
Ok(waivers) => waivers,
Err(refusals) => {
eprintln!(
"headwater: {}",
err("the `conformance` block of the consumer declaration did not read")
);
for refusal in &refusals {
eprintln!(" {}", err(refusal));
}
return ExitCode::FAILURE;
}
};
let Some(lock) = &loaded.bound.lock else {
return refuse(
"`headwater conformance` reads `.headwater/taxonomy.lock`, and this run holds a \
taxonomy that came out of a published artifact instead",
);
};
let projections = match headwater_generate::Projections::read(&loaded.bound.taxonomy) {
Ok(projections) => projections,
Err(errors) => return refused("the projections", &errors),
};
let surface = loaded.surface();
let plan = headwater_generate::plan(
&surface,
&loaded.census,
&projections,
&loaded.identity(),
&loaded.runs(root),
headwater_verbs::VERBS,
);
let report = match headwater_conformance::evaluate(
&set,
&waivers,
&headwater_conformance::Subject {
root,
consumer: &loaded.consumer,
lock,
census: &loaded.census,
plan: &plan,
now: context.now(),
},
) {
Ok(report) => report,
Err(refusals) => {
eprintln!(
"headwater: {}",
err("a waiver names no rule this package declares")
);
for refusal in &refusals {
eprintln!(" {refusal}");
}
return ExitCode::FAILURE;
}
};
if !json {
print!("{}", report.render(headwater_cli::paint::stdout_color()));
}
let gated = match level {
None => None,
Some(level) => match report.gate(level) {
Err(why) => return fail(&why),
Ok(passes) => Some((level, passes)),
},
};
if json {
print!("{}", headwater_conformance::json::report(&report, gated));
}
let Some((level, passes)) = gated else {
return ExitCode::SUCCESS;
};
match passes {
true => {
if !json {
println!(
"\n{level} passes, with every gap under it covered by a live waiver or met"
);
}
ExitCode::SUCCESS
}
false => {
eprintln!(
"headwater: {}",
err(&format!(
"{level} is not passed. Each gap above states the remediation the package \
wrote for it"
))
);
ExitCode::FAILURE
}
}
}
fn publish(
root: &Path,
package: Option<&str>,
from: Option<&Path>,
assembly: Option<&str>,
out: Option<&Path>,
clear_killed: bool,
json: bool,
) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let Some(out) = out else {
return fail("`taxonomy publish` writes into a directory. Name it with `--out <dir>`");
};
if package.is_some() && from.is_some() {
return fail(
"`--package <name>` and `--from <dir>` name the same thing two ways: the first finds \
a directory under `.headwater/packages/` by the name its manifest declares, and the second \
reads a directory the caller names directly. Pass one or the other",
);
}
if clear_killed {
match headwater_resolve::package::clear_killed(root, out) {
Ok(headwater_resolve::package::Cleared::Nothing) => {}
Ok(headwater_resolve::package::Cleared::KilledDirectWrite { out, staging }) => {
eprintln!(
"headwater: removed what a killed publish left at {} and its own directory at {}",
out.display(),
staging.display()
);
}
Err(errors) => {
eprintln!("headwater: {}", err("nothing was published"));
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
}
}
let published = match from {
Some(directory) => match assembly {
Some(name) => {
headwater_resolve::package::publish_assembly_from(root, directory, name, out)
}
None => headwater_resolve::package::publish_from_delivered(root, directory, out),
},
None => {
let name = match package {
Some(name) => name.to_string(),
None => match headwater_resolve::package::consumer(root) {
Ok(consumer) => consumer.package,
Err(errors) => {
eprintln!(
"headwater: {}",
err(
"no `--package`, no `--from`, and this repository's declaration \
does not read, so nothing says what to publish"
)
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
},
};
match assembly {
Some(assembly) => {
headwater_resolve::package::publish_assembly(root, &name, assembly, out)
}
None => headwater_resolve::package::publish_delivered(root, &name, out),
}
}
};
let headwater_resolve::package::Published {
release: record,
dropped,
delivery,
} = match published {
Ok(done) => done,
Err(errors) => {
eprintln!("headwater: {}", err("nothing was published"));
eprint!("{}", indent(&err(&render_errors(&errors))));
advise_recipe(root, package, from, assembly);
return ExitCode::FAILURE;
}
};
if !dropped.is_empty() {
eprintln!(
"headwater: the source declared {}, and nothing in the flattened package carries it",
dropped
.iter()
.map(|key| format!("`contents.{key}`"))
.collect::<Vec<_>>()
.join(", ")
);
eprintln!(
"{}",
indent(
"A migration payload states how one version line moves to the next, and a \
flattened package takes a new identity and a new version, so a payload written \
for the source package has no reader here. Publish the source package to ship \
it, or take the key out of the source manifest."
)
);
}
let recorded = headwater_resolve::package::recorded_references(out);
if !recorded.is_empty() {
eprintln!(
"headwater: the artifact records {} references that resolve nowhere inside it, and \
this publish carries no other",
recorded.len()
);
eprintln!(
"{}",
indent(&format!(
"`{}` in the manifest names each one as a carried document and a target the \
artifact does not hold, and the manifest ships with the artifact, so a consumer \
reads the same list. A reference this record does not hold is refused rather \
than reported. A pair the artifact stops dangling is a line to delete from it.",
headwater_resolve::package::RECORDED_REFERENCES
))
);
}
if let Some(shortfall) = delivery.shortfall() {
eprintln!(
"headwater: the artifact was written straight into the output directory, so this \
publish was not atomic"
);
eprintln!(
"{}",
indent(&format!(
"{shortfall}. Every other publish assembles the artifact beside the output \
directory and moves it into place with one step, so a run that is killed leaves \
that directory either untouched or complete. This one wrote file by file, so a \
run killed part-way leaves files there with no release record, and the next \
publish refuses until somebody clears them. `publish --json` says the same thing \
as `\"delivery\": \"direct\"`. Publish into a directory on an ordinary \
filesystem to get the guarantee back."
))
);
}
if json {
print!(
"{}",
headwater_resolve::release::document(&record, out, &delivery).render_pretty()
);
return ExitCode::SUCCESS;
}
println!(
"{} {} {}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Heading, "published", mode),
record.package,
record.version
);
println!(
" into {}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Path,
&out.display().to_string(),
mode
)
);
println!(" {} files", record.members.len());
if let Some(range) = &record.requires_engine {
println!(" for an engine in {range}");
}
println!(" digest {}", record.digest);
println!(
"\nState that digest where a consumer reads it, and never only inside the artifact. \
A consumer pins it as `taxonomy.digest` in `.headwater/taxonomy.yml`, and \
`headwater taxonomy vendor` checks a fetched copy against the pin."
);
ExitCode::SUCCESS
}
fn vendor(root: &Path, source: &str, expect: Option<&str>) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let consumer = headwater_resolve::package::consumer(root).ok();
let declared = consumer
.as_ref()
.and_then(|consumer| consumer.digest.clone());
let Some(pinned) = expect.map(str::to_string).or_else(|| declared.clone()) else {
return fail(
"nothing pins this artifact. A digest the engine took from the artifact in front of \
it is a pin against itself, so this refuses rather than records what it received. \
Write the publisher's digest as `taxonomy.digest` in `.headwater/taxonomy.yml`, or \
pass it with `--expect`",
);
};
let fetched = match fetch_location(source) {
Ok(fetched) => fetched,
Err(code) => return code,
};
let from = fetched
.as_ref()
.map_or(Path::new(source), |fetched| fetched.path());
let vendored = match headwater_resolve::package::vendor(root, from, &pinned) {
Ok(vendored) => vendored,
Err(errors) => {
let mut text = render_errors(&errors);
if from != Path::new(source) {
text = text.replace(&from.display().to_string(), source);
}
eprintln!("headwater: {}", err("nothing was vendored"));
eprint!("{}", indent(&err(&text)));
return ExitCode::FAILURE;
}
};
let record = &vendored.release;
println!(
"{} {} {}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Heading, "vendored", mode),
record.package,
record.version
);
println!(
" from {}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Path, source, mode)
);
println!(
" {} files, all of them the pinned bytes",
record.members.len()
);
println!(" digest {}", record.digest);
let flattened = record.package.replace('/', "-");
let installed = root
.join(headwater_resolve::package::PACKAGES)
.join(&flattened);
let doctrine = headwater_resolve::package::manifest_at(&installed)
.ok()
.and_then(|manifest| headwater_resolve::package::doctrine(&manifest));
if let Some(at) = doctrine {
println!(
" doctrine at {}/{}/{}",
headwater_resolve::package::PACKAGES,
flattened,
at.display()
);
println!(
"\nThe doctrine directory is prose the publisher wrote for a person to read. It is \
not schema, nothing resolves it, and no check reads it."
);
}
println!(
"\nThe digest says these are the bytes the pin was written for. It is not a signature, \
so it says nothing about who published them. Run `headwater taxonomy resolve` to write \
the lock this package produces."
);
if let Some(expected) = expect {
match (&consumer, &declared) {
(None, _) => println!(
" {} does not read as a consumer declaration, so no pin was written. Write \
`digest: {expected}` under `taxonomy:` there",
headwater_resolve::package::CONSUMER
),
(Some(_), None) => match record_pin(root, expected) {
Ok(()) => println!(
" pinned taxonomy.digest in {}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Path,
headwater_resolve::package::CONSUMER,
mode
)
),
Err(reason) => {
eprintln!(
"headwater: {}",
err(&format!(
"the package is installed, and the pin was not written: {reason}. \
Write `digest: {expected}` under `taxonomy:` in {} by hand",
headwater_resolve::package::CONSUMER
))
);
return ExitCode::FAILURE;
}
},
(Some(_), Some(declared)) if declared != expected => println!(
" {} declares taxonomy.digest {declared}, and this run installed {expected}. \
The declared pin is left as it is",
headwater_resolve::package::CONSUMER
),
(Some(_), Some(_)) => {}
}
}
if let Some(divergence) = &vendored.divergence {
println!(
"\nVersion {} of {} now names two sets of bytes.",
divergence.version, record.package
);
println!(
" the artifact that was installed {} {} files",
divergence.installed_digest, divergence.installed_members
);
println!(
" the artifact installed just now {} {} files",
record.digest,
record.members.len()
);
println!(
"\nA pin names bytes and a version number does not name an artifact. Nothing refuses \
a second publication of different bytes under a version already published, so this \
run installed the artifact you pinned and reports the pair rather than refusing it. \
Hold the digest, not the version, wherever a consumer states which artifact it \
received."
);
}
ExitCode::SUCCESS
}
fn record_pin(root: &Path, digest: &str) -> Result<(), String> {
let path = root.join(headwater_resolve::package::CONSUMER);
let original = std::fs::read_to_string(&path).map_err(|error| error.to_string())?;
let edited = with_pin(&original, digest)
.ok_or_else(|| "no block `taxonomy:` with keys on their own lines is there".to_string())?;
std::fs::write(&path, &edited).map_err(|error| error.to_string())?;
let read_back = headwater_resolve::package::consumer(root)
.ok()
.and_then(|consumer| consumer.digest);
if read_back.as_deref() == Some(digest) {
return Ok(());
}
std::fs::write(&path, &original).map_err(|error| error.to_string())?;
Err("the edited file did not read back with this digest, so it was restored".to_string())
}
const INIT_DIGEST_PLACEHOLDER: &str = "# digest: sha256:<the digest the publisher printed>";
fn with_pin(text: &str, digest: &str) -> Option<String> {
let lines: Vec<&str> = text.split_inclusive('\n').collect();
let start = lines
.iter()
.position(|line| line.trim_end() == "taxonomy:")?;
let end = lines[start + 1..]
.iter()
.position(|line| {
let first = line.chars().next();
!matches!(first, None | Some(' ' | '\t' | '#' | '\n' | '\r'))
})
.map_or(lines.len(), |offset| start + 1 + offset);
let block = &lines[start + 1..end];
let is_key = |line: &str| {
let trimmed = line.trim_start();
line.starts_with(' ') && !trimmed.starts_with('#') && trimmed.contains(':')
};
let first_key = block.iter().find(|line| is_key(line))?;
let indent = &first_key[..first_key.len() - first_key.trim_start().len()];
let is_key = |line: &str| {
is_key(line) && line.starts_with(indent) && !line[indent.len()..].starts_with([' ', '\t'])
};
let pin = format!("{indent}digest: {digest}\n");
let mut out: Vec<String> = lines.iter().map(|line| (*line).to_string()).collect();
let placeholder = block.iter().position(|line| {
line.strip_prefix(indent)
.is_some_and(|rest| rest.trim_end() == INIT_DIGEST_PLACEHOLDER)
});
if let Some(offset) = placeholder {
out[start + 1 + offset] = pin;
} else {
let key = block
.iter()
.position(|line| is_key(line) && line[indent.len()..].starts_with("version:"))
.or_else(|| block.iter().rposition(|line| is_key(line)))?;
let deeper = |line: &str| {
line.trim() != ""
&& line
.strip_prefix(indent)
.is_some_and(|rest| rest.starts_with([' ', '\t']))
};
let after = key
+ block[key + 1..]
.iter()
.take_while(|line| deeper(line))
.count();
let at = start + 1 + after;
if !out[at].ends_with('\n') {
out[at].push('\n');
}
out.insert(at + 1, pin);
}
Some(out.concat())
}
#[cfg(feature = "fetch")]
fn fetch_location(source: &str) -> Result<Option<headwater_fetch::Fetched>, ExitCode> {
if !headwater_fetch::is_location(source) {
return Ok(None);
}
headwater_fetch::fetch(source).map(Some).map_err(|error| {
eprintln!("headwater: {}", err("nothing was vendored"));
eprint!("{}", indent(&err(&error.to_string())));
ExitCode::FAILURE
})
}
#[cfg(not(feature = "fetch"))]
enum NoFetch {}
#[cfg(not(feature = "fetch"))]
impl NoFetch {
fn path(&self) -> &Path {
match *self {}
}
}
#[cfg(not(feature = "fetch"))]
fn fetch_location(source: &str) -> Result<Option<NoFetch>, ExitCode> {
let scheme = source.split_once("://").map_or("", |(scheme, _)| scheme);
if scheme.eq_ignore_ascii_case("https") || scheme.eq_ignore_ascii_case("http") {
return Err(fail(
"this binary was built without the `fetch` feature, so it takes no location. Fetch \
the artifact zip by other means, unpack it, and pass the directory: `headwater \
taxonomy vendor <dir> --expect <digest>`",
));
}
Ok(None)
}
fn artifact(
fetched: &Path,
to: Option<&str>,
) -> Result<headwater_resolve::release::Release, ExitCode> {
let record = match headwater_resolve::release::at(fetched) {
Ok(record) => record,
Err(error) => {
eprintln!(
"headwater: {}",
err(&format!(
"{} is not a published artifact this engine can read",
fetched.display()
))
);
eprintln!("{}", indent(&err(&error.to_string())));
return Err(ExitCode::FAILURE);
}
};
if let Err(error) = headwater_resolve::release::diverged(fetched, &record)
.map_err(|error| error.to_string())
.and_then(|diverged| match diverged.is_empty() {
true => Ok(()),
false => Err(diverged
.iter()
.map(|entry| entry.to_string())
.collect::<Vec<_>>()
.join("\n")),
})
{
eprintln!(
"headwater: {}",
err("the artifact is not what its own release record says it is")
);
eprintln!("{}", indent(&err(&error)));
return Err(ExitCode::FAILURE);
}
if let Some(range) = to {
match headwater_resolve::release::satisfies(range, &record.version) {
Err(why) => {
return Err(fail(&format!(
"`--to {range}` states no version comparison this engine reads: {why}"
)))
}
Ok(false) => {
return Err(fail(&format!(
"`--to {range}` and the artifact declares {}. The flag names the version the \
caller expected, and this verb fetches nothing, so a mismatch is a wrong \
directory rather than a wrong number",
record.version
)))
}
Ok(true) => {}
}
}
Ok(record)
}
fn migrate(
root: &Path,
fetched: &Path,
to: Option<&str>,
now: Option<Date>,
applying: bool,
) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let Some(_ctx) = now.map(Context::at).or_else(Context::from_system_clock) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let record = match artifact(fetched, to) {
Ok(record) => record,
Err(code) => return code,
};
let lock = match headwater_lock::at(root) {
Ok(lock) => lock,
Err(error) => {
eprintln!("headwater: {}", err(&format!("{error}")));
return ExitCode::FAILURE;
}
};
let committed_lock = lock.clone();
if record.package != lock.package {
return fail(&format!(
"this repository takes `{}` and the artifact publishes `{}`. Two packages are not \
two versions of one, and a migration between them is not a rename of anything",
lock.package, record.package
));
}
let from = lock.version.clone();
let to = record.version.clone();
if let Err(why) = headwater_compat::migrate::transition(&from, &to) {
return refuse(&why);
}
let manifest = match headwater_resolve::package::manifest_at(fetched) {
Ok(manifest) => manifest,
Err(errors) => {
eprintln!("headwater: {}", err("the artifact manifest did not read"));
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let payloads = match headwater_resolve::migration::at(fetched, &manifest) {
Ok(payloads) => payloads,
Err(refusals) => {
eprintln!(
"headwater: {}",
err("the artifact carries a migration payload this engine cannot read")
);
eprint!(
"{}",
indent(&err(&render_errors(
&headwater_resolve::migration::as_errors(
&fetched.display().to_string(),
&refusals,
)
)))
);
return ExitCode::FAILURE;
}
};
let mut selected: Vec<&headwater_resolve::migration::Payload> = Vec::new();
for carried in &payloads {
match carried.covers(&from, &to) {
Err(why) => {
return refuse(&format!(
"{} states a version range this engine cannot read: {why}",
carried.at
));
}
Ok(false) => {}
Ok(true) => selected.push(carried),
}
}
let payload = match selected.len() {
1 => selected[0],
0 => {
return refuse(&format!(
"the artifact ships no migration payload for {from} to {to}. Spec 2 makes a major \
version ship one, and this verb applies a payload rather than deriving one. \
`headwater taxonomy diff {}` reports what moved. Where every break is a facet \
that became required, no payload can cover it — that is a break no subject of \
this vocabulary reaches — and `headwater infer --owner <name> --write` records \
it as adoption debt instead",
fetched.display()
));
}
count => {
return refuse(&format!(
"{count} payloads of the artifact cover {from} to {to}, and a migration is not a \
choice of route: {}",
selected
.iter()
.map(|payload| payload.at.as_str())
.collect::<Vec<_>>()
.join(", ")
));
}
};
let taking = match load_against(root, Bound::of(lock)) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let consumer = match headwater_resolve::package::consumer(root) {
Ok(consumer) => consumer,
Err(errors) => {
eprintln!(
"headwater: {}",
err("the consumer declaration did not read")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let overlay = match headwater_resolve::package::adopted(root, &consumer) {
Ok(overlay) => overlay,
Err(errors) => {
eprintln!(
"headwater: {}",
err("this repository's overlay did not read")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
println!(
"\n{} {}, from {from} to {to}\n payload {}\n",
headwater_cli::paint::paint(headwater_cli::paint::Role::Heading, "migration", mode),
record.package,
headwater_cli::paint::paint(headwater_cli::paint::Role::Path, &payload.at, mode)
);
let candidate = headwater_resolve::package::sources_at(root, fetched, &manifest, &consumer)
.and_then(|sources| headwater_resolve::resolve(&sources));
let standing: Vec<String> = match &candidate {
Ok(candidate) => payload
.steps
.iter()
.filter(|step| {
headwater_resolve::migration::declares(
&taking.bound.taxonomy,
&step.subject,
&step.from,
) && headwater_compat::payload::stands(candidate, &step.subject, &step.from)
})
.map(|step| step.at())
.collect(),
Err(_) => {
println!(
" the candidate did not resolve under this repository's overlays, so this run \
could not tell whether any step of this payload names a value this repository \
would still declare. `headwater taxonomy diff` measures that\n"
);
Vec::new()
}
};
let mut moves: Vec<headwater_scaffold::migrate::Move> = Vec::new();
let mut readdressed: Vec<headwater_scaffold::overlay::Move> = Vec::new();
let mut placed: Vec<String> = Vec::new();
let mut mechanical = 0;
for step in &payload.steps {
let headwater_resolve::migration::Application::Mechanical { to } = &step.apply else {
continue;
};
mechanical += 1;
println!(" {} becomes `{to}`", step.at());
if standing.contains(&step.at()) {
println!(" {}\n", headwater_compat::payload::STANDS);
continue;
}
let sites = headwater_compat::migrate::sites(step, &taking.census, &overlay);
match sites.is_empty() {
true => println!(" {}", step.subject.reached_nothing()),
false => {
for site in &sites {
match site {
headwater_compat::migrate::Site::Front { path, key } => {
println!(
" {} `{key}`",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Path,
path,
mode
)
);
moves.push(headwater_scaffold::migrate::Move {
path: path.clone(),
key: key.clone(),
from: step.from.clone(),
to: to.clone(),
});
}
headwater_compat::migrate::Site::Placement { .. } => {
let why = site.why().expect("a placement site states one");
println!(" {why}");
placed.push(why);
}
headwater_compat::migrate::Site::Overlay {
path,
at,
address,
span,
} => {
let Some(moved) =
headwater_resolve::migration::readdressed(address, &step.from, to)
else {
return refuse(&format!(
"{at} in {path} is not addressed under `{}`, so this run \
cannot say what it becomes",
step.from
));
};
println!(" {path} {at} becomes `{moved}`");
readdressed.push(headwater_scaffold::overlay::Move {
at: at.clone(),
address: address.clone(),
to: moved,
span: *span,
});
}
}
}
}
}
println!();
}
if mechanical == 0 {
println!(" no step of this payload applies mechanically\n");
}
let judgment: Vec<&headwater_resolve::migration::Step> = payload
.steps
.iter()
.filter(|step| !step.apply.mechanical())
.collect();
println!(
" {} task{} for an author, and no run writes any of them",
judgment.len(),
match judgment.len() {
1 => "",
_ => "s",
}
);
for step in &judgment {
println!("\n {} {}", step.at(), step.apply.sentence());
println!(
" task {}",
step.apply.task().expect("a judgment step carries one")
);
if standing.contains(&step.at()) {
println!(" {}", headwater_compat::payload::STANDS);
continue;
}
let sites = headwater_compat::migrate::sites(step, &taking.census, &overlay);
match sites.is_empty() {
true => println!(" {}", step.subject.reached_nothing()),
false => {
for site in &sites {
println!(" {}", site.key());
}
}
}
}
println!();
match &consumer.digest {
Some(digest) => println!(
" the lock records this migration on `--apply`: `adoption.from` becomes \
{{version: {from}, digest: {digest}}} and `adoption.to` becomes {to} \
(HW-DR-0046). The open task set, if any, is carried through unchanged"
),
None => println!(
" `.headwater/taxonomy.yml` pins no digest, so this run cannot write a verifiable \
`adoption.from` (HW-DR-0046). Take the digest the publisher states and write it as \
`taxonomy.digest` in `.headwater/taxonomy.yml`, by hand or with `headwater taxonomy \
vendor <dir-or-location> --expect <digest>`, and a later run of this verb records \
the migration. Every other file below is still \
written on `--apply`"
),
}
if !placed.is_empty() {
println!(
"\n {} document{} take{} a kind this payload renames from a homogeneous shelf, so no \
byte of the document holds it and the remedy is a file move",
placed.len(),
match placed.len() {
1 => "",
_ => "s",
},
match placed.len() {
1 => "s",
_ => "",
}
);
}
if applying && !standing.is_empty() {
return refuse(&format!(
"{} step{} of this payload name{} a value the taxonomy this artifact gives this \
repository still declares, so applying this payload would rewrite documents into a \
name that did not move for this selection: {}. A bundle is add-only, so a value only \
a bundle declares moved for the consumers who selected that bundle and for no other, \
and `taxonomy publish` holds the source half against the base alone. Ask the \
publisher whether the bundles this repository selects were meant to move too",
standing.len(),
match standing.len() {
1 => "",
_ => "s",
},
match standing.len() {
1 => "s",
_ => "",
},
standing.join(", ")
));
}
let mut written = headwater_scaffold::migrate::compose(root, &moves);
if !written.refused.is_empty() {
eprintln!(
"\nheadwater: {} document{} did not compose, and this run writes nothing",
written.refused.len(),
match written.refused.len() {
1 => "",
_ => "s",
}
);
for refused in &written.refused {
eprintln!("{}", indent(&err(&refused.to_string())));
}
return ExitCode::FAILURE;
}
let overlay_at = overlay.at().unwrap_or_default().to_string();
match headwater_scaffold::overlay::compose(root, &overlay_at, &readdressed) {
Err(refused) => {
eprintln!(
"\nheadwater: this repository's overlay did not compose, and this run \
writes nothing"
);
eprintln!("{}", indent(&err(&refused.to_string())));
return ExitCode::FAILURE;
}
Ok(None) => {}
Ok(Some((composed, count))) => {
written.files.push(composed);
written.replaced += count;
}
}
let files = written.files.len() + usize::from(consumer.digest.is_some());
if !applying {
println!(
"\n {} value{} in {} file{} would be written. Nothing was: pass `--apply`",
written.replaced,
match written.replaced {
1 => "",
_ => "s",
},
files,
match files {
1 => "",
_ => "s",
}
);
return ExitCode::SUCCESS;
}
if let Some(digest) = &consumer.digest {
let mut state = String::new();
state.push_str("from:\n");
state.push_str(&format!(" version: {}\n", quoted(&from)));
state.push_str(&format!(" digest: {}\n", quoted(digest)));
state.push_str(&format!("to: {}\n", quoted(&to)));
state.push_str("tasks: []\n");
let fresh =
match headwater_yaml::load(&state) {
Ok(node) => match node.value.as_map() {
Some(map) => map.clone(),
None => return defect(
"the migration state this run built is not a mapping, which is a defect",
),
},
Err(errors) => {
return defect(&format!(
"the migration state this run built does not load: {}",
headwater_yaml::error::render(&errors)
))
}
};
let block = migrated(committed_lock.adoption.as_ref(), &fresh);
let text = headwater_lock::rewrite_adoption(&committed_lock, Some(&block));
written.files.push(headwater_scaffold::tree::Composed {
path: headwater_lock::LOCK.to_string(),
text,
});
}
let replaced = written.replaced;
let reserved = match headwater_scaffold::tree::Reserved::over(root, written.files) {
Ok(reserved) => reserved,
Err(unopened) => {
eprintln!("\nheadwater: a file of this migration cannot be written, so none was");
eprintln!("{}", indent(&err(&unopened.to_string())));
return ExitCode::FAILURE;
}
};
match reserved.commit() {
Ok(paths) => {
println!(
"\n wrote {replaced} value{} in {} file{}",
match replaced {
1 => "",
_ => "s",
},
paths.len(),
match paths.len() {
1 => "",
_ => "s",
}
);
for path in &paths {
println!(" {path}");
}
ExitCode::SUCCESS
}
Err(halted) => {
eprintln!("\nheadwater: the migration stopped part way");
eprintln!("{}", indent(&err(&halted.to_string())));
ExitCode::FAILURE
}
}
}
fn diff(root: &Path, fetched: &Path, to: Option<&str>, now: Option<Date>) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let Some(ctx) = now.map(Context::at).or_else(Context::from_system_clock) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let record = match artifact(fetched, to) {
Ok(record) => record,
Err(code) => return code,
};
let lock = match headwater_lock::at(root) {
Ok(lock) => lock,
Err(error) => {
eprintln!("headwater: {}", err(&format!("{error}")));
return ExitCode::FAILURE;
}
};
let consumer = match headwater_resolve::package::consumer(root) {
Ok(consumer) => consumer,
Err(errors) => {
eprintln!(
"headwater: {}",
err("the consumer declaration did not read")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let overlay = match headwater_resolve::package::adopted(root, &consumer) {
Ok(overlay) => overlay,
Err(errors) => {
eprintln!(
"headwater: {}",
err("this repository's overlay did not read")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
if record.package != lock.package {
return fail(&format!(
"this repository takes `{}` and the artifact publishes `{}`. Two packages are not \
two versions of one, and none of the six dimensions is a question about them",
lock.package, record.package
));
}
let manifest = match headwater_resolve::package::manifest_at(fetched) {
Ok(manifest) => manifest,
Err(errors) => {
eprintln!("headwater: {}", err("the artifact manifest did not read"));
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let candidate = headwater_resolve::package::sources_at(root, fetched, &manifest, &consumer)
.and_then(|sources| headwater_resolve::resolve(&sources));
let carried = lock.founded.clone();
let moved_sources = lock.moved(root);
let (resolution, addressability, tasks) = match candidate {
Ok(resolution) => {
let outcome = headwater_compat::addressability(
&carried,
&resolution.founding_records(),
Vec::new(),
);
(Some(resolution), outcome, String::new())
}
Err(errors) => {
let breaks: Vec<headwater_compat::Break> = errors
.iter()
.filter(|error| error.kind.names_an_address())
.map(|error| headwater_compat::Break {
at: format!("{} in {}", error.at, error.source),
was: "an address this overlay resolves against".to_string(),
now: error.to_string(),
})
.collect();
let outcome = match breaks.is_empty() {
true => headwater_compat::Outcome::NotMeasured(format!(
"the candidate did not resolve, and no refusal named an overlay address: {}",
render_errors(&errors).trim()
)),
false => headwater_compat::Outcome::over(breaks),
};
(None, outcome, headwater_resolve::error::collisions(&errors))
}
};
let Some(resolution) = resolution else {
let report = headwater_compat::Report {
package: record.package.clone(),
from: lock.version.clone(),
to: record.version.clone(),
base: headwater_compat::Base::Unresolved,
moved_sources: moved_sources.clone(),
measured: headwater_compat::Measured::against_nothing(
addressability,
"the candidate taxonomy did not resolve under this repository's overlays, so no \
phase ran against it",
),
};
print!("{}", report.render(mode));
print!("{tasks}");
eprintln!(
"headwater: {}",
err(
"the candidate did not resolve, so five of the six dimensions were not \
measured. The lines above are what this run does know"
)
);
return ExitCode::FAILURE;
};
let refusals = resolution.validate();
let base = match headwater_lock::digest(&resolution.render()) == lock.digest {
true => headwater_compat::Base::Same,
false => headwater_compat::Base::Moved,
};
let package = record.package.clone();
let from = lock.version.clone();
let lock_version = lock.version.clone();
let to = record.version.clone();
let taking = match load_against(root, Bound::of(lock)) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let against = match load_against(
root,
Bound::candidate(
&package,
&to,
&resolution,
taking.bound.adoption.as_ref(),
&fetched.display().to_string(),
),
) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let before = run_of(&taking, &ctx);
let after = run_of(&against, &ctx);
let identity = taking.identity();
let (classification, reclassified) =
headwater_compat::classification(&taking.census, &against.census);
let (validity, invalidated) = headwater_compat::instance_validity(&before, &after);
let moved = Movement {
documents: reclassified.union(&invalidated).cloned().collect(),
rules: match &classification {
headwater_compat::Outcome::Broken(_) => std::collections::BTreeSet::new(),
_ => headwater_compat::broken_rules(&validity),
},
};
let measured = headwater_compat::Measured {
classification,
instance_validity: validity,
consequence: headwater_compat::consequence(&before, &after),
projection: match (
plan_of(root, &taking, &identity),
plan_of(root, &against, &identity),
) {
(Ok(before), Ok(after)) => headwater_compat::projection(&before, &after),
(before, after) => headwater_compat::Outcome::NotMeasured(format!(
"a projection declaration did not read: {}",
[before.err(), after.err()]
.into_iter()
.flatten()
.collect::<Vec<_>>()
.join("; ")
)),
},
identifier: headwater_compat::identifier(&taking.graph, &against.graph),
addressability,
};
let report = headwater_compat::Report {
package,
from,
to,
base,
moved_sources,
measured,
};
print!("{}", report.render(mode));
if let Err(code) = payload(
fetched,
&manifest,
Sides {
taking: &taking,
candidate: &resolution,
},
&overlay,
&lock_version,
&record.version,
&moved,
mode,
) {
return code;
}
if !refusals.is_empty() {
println!(
"\nthe candidate resolves under this overlay and {} rule{} of `taxonomy validate` \
refuses the result",
refusals.len(),
match refusals.len() {
1 => "",
_ => "s",
}
);
print!("{}", indent(&render_errors(&refusals)));
}
ExitCode::SUCCESS
}
struct Sides<'a> {
taking: &'a Loaded,
candidate: &'a headwater_resolve::Resolution,
}
#[allow(clippy::too_many_arguments)]
fn payload(
fetched: &Path,
manifest: &headwater_yaml::Mapping,
sides: Sides<'_>,
overlay: &headwater_resolve::Adopted,
from: &str,
to: &str,
moved: &Movement,
mode: headwater_cli::paint::ColorMode,
) -> Result<(), ExitCode> {
let Sides { taking, candidate } = sides;
let payloads = match headwater_resolve::migration::at(fetched, manifest) {
Ok(payloads) => payloads,
Err(refusals) => {
eprintln!(
"headwater: {}",
err("the artifact carries a migration payload this engine cannot read")
);
eprint!(
"{}",
indent(&err(&render_errors(
&headwater_resolve::migration::as_errors(
&fetched.display().to_string(),
&refusals,
)
)))
);
return Err(ExitCode::FAILURE);
}
};
let taxonomy = &taking.bound.taxonomy;
let declares = |step: &headwater_resolve::migration::Step| {
headwater_resolve::migration::declares(taxonomy, &step.subject, &step.from)
};
let stands = |step: &headwater_resolve::migration::Step| {
headwater_compat::payload::stands(candidate, &step.subject, &step.from)
};
let mut selected = 0;
for carried in &payloads {
match carried.covers(from, to) {
Err(why) => {
println!(
"\n{} states a version range this engine cannot read, so nothing selected it: \
{why}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Path,
&carried.at,
mode
)
);
}
Ok(false) => {}
Ok(true) => {
selected += 1;
print!(
"{}",
headwater_compat::payload::account(
carried,
&taking.census,
overlay,
declares,
stands,
&moved.documents
)
.render(mode)
);
}
}
}
if selected == 0 && !moved.documents.is_empty() {
let documents = format!(
"{} document{}",
moved.documents.len(),
match moved.documents.len() {
1 => "",
_ => "s",
}
);
match unreachable_by_a_step(&moved.rules) {
true => println!(
"\nthe artifact ships no migration payload for {from} to {to}, and {documents} \
stopped validating. No step can express this break: a facet that became \
required is none of the three subjects spec 7 declares, and there is no old \
value for a `facet_value` step to move. `headwater infer --owner <name> \
--write` records the breakage as adoption debt, and `headwater check` then \
reports it as migration-pending"
),
false => println!(
"\nthe artifact ships no migration payload for {from} to {to}, and {documents} \
stopped validating. Spec 2 makes a major version ship one"
),
}
}
Ok(())
}
struct Movement {
documents: std::collections::BTreeSet<String>,
rules: std::collections::BTreeSet<String>,
}
const FACET_REQUIRED: &str = "facet.required.missing";
fn unreachable_by_a_step(broken: &std::collections::BTreeSet<String>) -> bool {
!broken.is_empty() && broken.iter().all(|rule| rule == FACET_REQUIRED)
}
fn run_of(loaded: &Loaded, ctx: &Context) -> headwater_check::Run {
let mut cache = Cache::disabled();
headwater_check::run(
&loaded.census,
&loaded.graph,
&loaded.declared(),
&loaded.claims,
ctx,
&mut cache,
)
}
fn plan_of(
root: &Path,
loaded: &Loaded,
identity: &headwater_generate::Identity,
) -> Result<headwater_generate::Plan, String> {
let projections =
headwater_generate::Projections::read(&loaded.bound.taxonomy).map_err(|errors| {
errors
.iter()
.map(|error| error.to_string())
.collect::<Vec<_>>()
.join("; ")
})?;
Ok(headwater_generate::plan(
&loaded.surface(),
&loaded.census,
&projections,
identity,
&loaded.runs(root),
headwater_verbs::VERBS,
))
}
struct Bound {
package: String,
version: String,
digest: String,
taxonomy: headwater_yaml::Mapping,
adoption: Option<headwater_yaml::Mapping>,
source: String,
lock: Option<headwater_lock::Lock>,
}
impl Bound {
fn of(lock: headwater_lock::Lock) -> Bound {
Bound {
package: lock.package.clone(),
version: lock.version.clone(),
digest: lock.digest.clone(),
taxonomy: lock.taxonomy.clone(),
adoption: lock.adoption.clone(),
source: headwater_lock::LOCK.to_string(),
lock: Some(lock),
}
}
fn candidate(
package: &str,
version: &str,
resolution: &headwater_resolve::Resolution,
adoption: Option<&headwater_yaml::Mapping>,
source: &str,
) -> Bound {
Bound {
package: package.to_string(),
version: version.to_string(),
digest: headwater_lock::digest(&resolution.render()),
taxonomy: resolution.taxonomy.clone(),
adoption: adoption.cloned(),
source: source.to_string(),
lock: None,
}
}
}
struct Loaded {
bound: Bound,
consumer: headwater_resolve::package::Consumer,
census: headwater_census::census::Census,
graph: Graph,
shape: Shape,
taxonomy: Taxonomy,
relations: Declarations,
register: Register,
observations: headwater_check::Observations,
config: Config,
claims: headwater_check::claim::Claims,
}
fn load(root: &Path) -> Result<Loaded, ExitCode> {
let lock = match headwater_lock::at(root) {
Ok(lock) => lock,
Err(error) => {
eprintln!("headwater: {}", err(&format!("{error}")));
return Err(ExitCode::FAILURE);
}
};
load_against(root, Bound::of(lock))
}
fn load_against(root: &Path, bound: Bound) -> Result<Loaded, ExitCode> {
let consumer = match headwater_resolve::package::consumer(root) {
Ok(consumer) => consumer,
Err(errors) => {
eprintln!(
"headwater: {}",
err("the consumer declaration did not read")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return Err(ExitCode::FAILURE);
}
};
let corpus = Corpus::declared(root, &consumer.corpus_root, &consumer.exclusions);
let resolved = &bound.taxonomy;
let taxonomy = match Taxonomy::read(resolved) {
Ok(taxonomy) => taxonomy,
Err(errors) => return Err(refused("the taxonomy", &errors)),
};
let relations = match Declarations::read(resolved) {
Ok(declarations) => declarations,
Err(errors) => return Err(refused("the relation declarations", &errors)),
};
let register = match Register::read(resolved) {
Ok(register) => register,
Err(errors) => return Err(refused("the obligations and controls", &errors)),
};
let shape = match Shape::read(resolved) {
Ok(shape) => shape,
Err(errors) => return Err(refused("the facet and kind declarations", &errors)),
};
let mut resolvers = Resolvers::over(&corpus);
resolvers = match resolvers.with(Box::new(headwater_check::anchors::Rules::shipped())) {
Ok(resolvers) => resolvers,
Err(why) => {
eprintln!("headwater: {}", err("the resolver set is ambiguous"));
eprintln!("{}", indent(&err(&why)));
return Err(ExitCode::FAILURE);
}
};
let imports = match headwater_import::declared(root) {
Ok(imports) => imports,
Err(why) => {
eprintln!("headwater: {}", err("the import declarations did not read"));
eprintln!("{}", indent(&err(&why)));
return Err(ExitCode::FAILURE);
}
};
for items in headwater_import::anchors::over(root, &imports) {
resolvers = match resolvers.with(Box::new(items)) {
Ok(resolvers) => resolvers,
Err(why) => {
eprintln!("headwater: {}", err("the resolver set is ambiguous"));
eprintln!("{}", indent(&err(&why)));
return Err(ExitCode::FAILURE);
}
};
}
if let Some(pattern) = relations
.anchors
.iter()
.find(|anchor| anchor.resolver == "comment-scan")
.and_then(|anchor| anchor.pattern.clone())
{
let minted = headwater_graph::anchors::CommentScan::claimed(root);
resolvers = match resolvers.with(Box::new(headwater_graph::anchors::CommentScan::new(
root, pattern, minted,
))) {
Ok(resolvers) => resolvers,
Err(why) => {
eprintln!("headwater: {}", err("the resolver set is ambiguous"));
eprintln!("{}", indent(&err(&why)));
return Err(ExitCode::FAILURE);
}
};
}
let census = census::take(&corpus, &taxonomy);
let config = Config::default();
let graph = Graph::build(&census, &relations, &resolvers, &corpus, &config);
Ok(Loaded {
bound,
consumer,
census,
graph,
shape,
taxonomy,
relations,
register,
observations: headwater_check::Observations::at(root),
config,
claims: headwater_check::claim::Claims::at(root),
})
}
impl Loaded {
fn declared(&self) -> Declared<'_> {
Declared {
lock: &self.bound.digest,
taxonomy: &self.taxonomy,
shape: &self.shape,
relations: &self.relations,
config: &self.config,
register: &self.register,
observations: &self.observations,
adoption: self.bound.adoption.as_ref(),
source: &self.bound.source,
}
}
fn surface(&self) -> Surface<'_> {
Surface::over(
&self.census,
&self.graph,
&self.shape,
&self.taxonomy,
&self.relations,
&self.config,
)
}
fn runs(&self, root: &Path) -> headwater_generate::Runs {
let mut runs = headwater_generate::Runs::default();
for row in &self.census.rows {
let headwater_census::census::Outcome::Typed { kind, .. } = &row.outcome else {
continue;
};
if kind != headwater_probe::intake::KIND {
continue;
}
if let Ok(source) = std::fs::read_to_string(root.join(&row.path)) {
runs.transcripts.push(headwater_generate::Transcript {
path: row.path.clone(),
source,
});
}
}
let Ok(declaration) = std::fs::read_to_string(root.join(headwater_probe::budget::PATH))
else {
return runs;
};
let Ok(budgets) = headwater_probe::Budgets::read(&declaration) else {
return runs;
};
runs.graded_against(&headwater_probe::Plan::over(
&self.census,
&self.graph,
&self.config,
&budgets,
&self.bound.digest,
headwater_probe::Tier::Regression,
&headwater_probe::plan::Narrowing::default(),
));
runs
}
fn identity(&self) -> headwater_generate::Identity {
headwater_generate::Identity {
corpus_root: self.consumer.corpus_root.clone(),
exclusions: self.consumer.exclusions.clone(),
package: self.bound.package.clone(),
version: self.bound.version.clone(),
lock: self.bound.digest.clone(),
}
}
}
fn route(root: &Path, task: &str, budget: Option<usize>, json: bool) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let budget = match budget {
Some(pointers) => Budget { pointers },
None => Budget::default(),
};
let route = loaded.surface().route(task, budget);
match json {
true => print!("{}", headwater_query::json::route(&route)),
false => print!("{}", route.render(headwater_cli::paint::stdout_color())),
}
ExitCode::SUCCESS
}
fn neighbors(
root: &Path,
task: &str,
model: Option<PathBuf>,
top: Option<usize>,
json: bool,
) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let pin = match headwater_embed::Pin::read(root) {
Ok(pin) => pin,
Err(message) => return fail(&message),
};
let dir = model.unwrap_or_else(|| root.join(headwater_embed::MODELS));
let embedder = match headwater_embed::Model::load(&pin, &dir) {
Ok(embedder) => embedder,
Err(message) => return fail(&message),
};
let top = top.unwrap_or(10);
let surface = loaded.surface();
let mut cache = headwater_embed::Cache::open(root, embedder.digest());
let query = match embedder.embed(task) {
Ok(vector) => vector,
Err(message) => return fail(&message),
};
let mut ranked: Vec<(f32, String, String)> = Vec::new();
for document in surface.documents() {
let Some(summary) = surface.summary(&document).filter(|s| !s.trim().is_empty()) else {
continue;
};
let vector = match cache.vector(&embedder, &summary) {
Ok(vector) => vector,
Err(message) => return fail(&message),
};
ranked.push((
headwater_embed::similarity(&query, &vector),
document.path.to_string(),
headwater_hash::digest(summary.as_bytes()),
));
}
cache.write(embedder.digest());
ranked.sort_by(|a, b| b.0.total_cmp(&a.0).then_with(|| a.1.cmp(&b.1)));
let considered = ranked.len();
ranked.truncate(top);
let tree = headwater_probe::plan::tree_digest(&loaded.census);
match json {
true => {
use headwater_yaml::json::Json;
let document = Json::object([
("task", Json::string(task)),
("tree_digest", Json::string(tree)),
("lock_digest", Json::string(loaded.bound.digest.clone())),
("model", Json::string(pin.model.clone())),
("model_digest", Json::string(embedder.digest())),
("considered", Json::Raw(considered.to_string())),
(
"neighbors",
Json::Array(
ranked
.iter()
.map(|(score, path, summary)| {
Json::object([
("path", Json::string(path.clone())),
("score", Json::Raw(format!("{score:.4}"))),
("summary_digest", Json::string(summary.clone())),
])
})
.collect(),
),
),
]);
println!("{}", document.render());
}
false => {
println!("neighbors \"{task}\"");
println!(" model {} {}", pin.model, embedder.digest());
println!(" tree {tree}");
println!(" lock {}", loaded.bound.digest);
println!();
for (score, path, _) in &ranked {
println!(" {score:.4} {path}");
}
println!(
"\n{} of {considered} summarized documents. Nothing an agent reads comes from this \
ranking (HW-DR-0064).",
ranked.len()
);
}
}
eprintln!(
"headwater: {} of {considered} summary vectors computed, the rest read from {}",
cache.computed(),
headwater_embed::CACHE
);
ExitCode::SUCCESS
}
fn explain(root: &Path, target: &str, json: bool) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
match loaded.surface().explain(target) {
Some(explanation) => {
let explanation: headwater_query::Explanation = explanation;
match json {
true => print!("{}", headwater_query::json::explain(&explanation)),
false => print!(
"{}",
explanation.render(headwater_cli::paint::stdout_color())
),
}
ExitCode::SUCCESS
}
None => {
let text = match loaded.shape.identifier_shaped(target) {
true => identifier_text(target),
false => {
let corpus = Corpus::declared(
root,
&loaded.consumer.corpus_root,
&loaded.consumer.exclusions,
);
classification_text(target, &corpus.classify(Path::new(target)))
}
};
eprintln!("headwater: {}", err(&text));
ExitCode::FAILURE
}
}
}
fn identifier_text(target: &str) -> String {
format!("`{target}` is shaped like an identifier of this corpus, and no document declares it")
}
fn classification_text(
target: &str,
classification: &headwater_census::walk::Classification,
) -> String {
use headwater_census::walk::Classification;
match classification {
Classification::Corpus => {
format!("`{target}` is a path of this corpus, with no document written there yet")
}
Classification::Excluded(pattern) => {
format!("`{target}` is excluded by `{pattern}`, so it is not corpus content")
}
Classification::Outside => {
format!("`{target}` is outside every corpus root this repository declares")
}
Classification::Unclassifiable => {
format!("`{target}` is not a path this repository can classify")
}
}
}
fn new(
root: &Path,
kind: &str,
title: Option<String>,
summary: Option<String>,
relates: &[(String, String)],
given: &[(String, String)],
now: Option<Date>,
) -> ExitCode {
let Some(title) = title else {
return fail(
"`new` takes `--title <text>`. The file name and the document's own name both come \
from it, and this engine invents neither",
);
};
match scaffold(
root,
kind,
&title,
summary.as_deref(),
relates,
given,
now,
EntryPoint::Terminal,
) {
Err(why) => refuse(&why),
Ok(written) => {
print!("{}", written.artifact);
eprint!("{}", written.account);
match written.ok {
true => ExitCode::SUCCESS,
false => ExitCode::FAILURE,
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn scaffold(
root: &Path,
kind: &str,
title: &str,
summary: Option<&str>,
relates: &[(String, String)],
given: &[(String, String)],
now: Option<Date>,
surface: EntryPoint,
) -> Result<Written, String> {
let mode = match surface {
EntryPoint::Terminal => headwater_cli::paint::stdout_color(),
EntryPoint::Protocol => headwater_cli::paint::ColorMode::Plain,
};
let loaded = load(root).map_err(|_| "the corpus did not load".to_string())?;
let now = match now {
Some(now) => now,
None => match Context::from_system_clock() {
Some(context) => context.now(),
None => {
return Err(
"the host clock is before the epoch, and this engine will not guess a date"
.to_string(),
)
}
},
};
let index = headwater_graph::index::Index::build(&loaded.census, &loaded.config);
let sources = headwater_scaffold::Sources {
resolved: &loaded.bound.taxonomy,
shape: &loaded.shape,
shelves: &loaded.taxonomy,
relations: &loaded.relations,
census: &loaded.census,
index: &index,
config: &loaded.config,
claims: &loaded.claims,
};
let request = headwater_scaffold::Request {
kind,
title,
summary,
now,
relates,
given,
};
let plan = headwater_scaffold::propose(&sources, &request).map_err(|why| why.to_string())?;
let composed =
headwater_scaffold::write::compose(root, &plan).map_err(|why| why.to_string())?;
let claimed = headwater_scaffold::claim::write(root, &plan).map_err(|why| why.to_string())?;
headwater_scaffold::write::apply(root, &composed).map_err(|why| why.to_string())?;
let reading =
headwater_scaffold::reading::Reading::of(&plan, &loaded.bound.digest, now, surface);
let recorded = headwater_scaffold::reading::append(root, &reading);
Ok(Written {
artifact: scaffold_report(&plan, &composed, claimed.as_deref(), recorded.is_ok(), mode),
account: match &recorded {
Ok(()) => String::new(),
Err(why) => format!(
"headwater: the document landed and its capture-cost reading did not. {why}. \
Append this line to `{}` by hand, or the run is invisible to `headwater \
capture`:\n{}\n",
headwater_scaffold::reading::STORE,
reading.render()
),
},
landed: true,
ok: recorded.is_ok(),
})
}
fn scaffold_report(
plan: &headwater_scaffold::Plan,
composed: &[headwater_scaffold::write::Composed],
claimed: Option<&str>,
recorded: bool,
mode: headwater_cli::paint::ColorMode,
) -> String {
use headwater_cli::paint::{paint, Role};
use std::fmt::Write;
let mut out = String::new();
for file in composed {
let verb = match file.created {
true => "wrote",
false => "edited",
};
let _ = writeln!(out, "{verb} {}", paint(Role::Path, &file.path, mode));
}
if let Some(claim) = claimed {
let _ = writeln!(out, "claimed {claim}");
}
let _ = writeln!(
out,
"\n{}",
paint(Role::Heading, "what the taxonomy decided", mode)
);
let _ = writeln!(out, " kind {} on the shelf `{}`", plan.kind, plan.shelf);
if let Some(minting) = &plan.minting {
let _ = writeln!(
out,
" identifier {} under `{}`, allocation {}",
minting.id,
minting.scheme,
minting.allocation.as_deref().unwrap_or("unstated")
);
if let Some(highest) = minting.reconciled_from {
let _ = writeln!(
out,
" reconciled against {highest}, which is the highest value this tree and \
the identifier claim store carry between them. A document that was deleted is \
not on the tree, and the store outlives it, so the pair is a lower bound only \
on a value that no claim recorded"
);
}
} else {
let _ = writeln!(
out,
" no identifier: `{}` names no scheme, and no relation may name a document of it",
plan.kind
);
}
for field in &plan.fields {
let _ = writeln!(out, " {} — {}", field.key, field.origin.reason());
}
for section in &plan.sections {
let _ = writeln!(
out,
" section `{}` — the kind requires it",
section.heading
);
}
if !plan.edges.is_empty() {
let _ = writeln!(
out,
"\n{}",
paint(Role::Heading, "the edges it proposed", mode)
);
for edge in &plan.edges {
let _ = writeln!(
out,
" {} {} — `created_by: {}`, so a scaffold pays for it",
edge.relation, edge.target, edge.created_by
);
match &edge.reciprocal {
Some(half) => {
let _ = writeln!(
out,
" the far half `{}` went into {}, because reciprocity is required",
half.relation,
paint(Role::Path, &half.path, mode)
);
}
None => {
let _ = writeln!(out, " the relation asks for no far half");
}
}
}
let _ = writeln!(
out,
" no facet of another document moved. `on_target` is a lifecycle event, and no \
rule of this engine reads a transition"
);
}
if !plan.expected.is_empty() {
let _ = writeln!(
out,
"\n{}",
paint(
Role::Heading,
"what this document may also declare, and nobody did",
mode
)
);
for expected in &plan.expected {
let _ = writeln!(
out,
" {} to {} — `created_by: {}`",
expected.relation,
expected.to.join(", "),
expected.created_by
);
}
}
if let Some(language) = &plan.language {
let _ = writeln!(
out,
"\n{}",
paint(Role::Heading, "what this document's prose answers to", mode)
);
let _ = writeln!(
out,
" the `{}` language regime — {}",
language.regime, language.declared
);
if language.mechanically_checked {
let _ = writeln!(
out,
" `headwater check` reads sentence length, a semicolon in running prose, a \
contraction and a British spelling mechanically, plus {} retired term{} this \
taxonomy names",
language.retired_terms,
if language.retired_terms == 1 { "" } else { "s" }
);
} else {
let _ = writeln!(
out,
" `headwater check` has no mechanical rule for this pair, and reads none of it"
);
}
if language.voice_forbids.is_empty() {
let _ = writeln!(out, " no voice regime forbids a construction here");
} else {
let _ = writeln!(
out,
" it also reads for {} forbidden construction{} a voice regime names: {}",
language.voice_forbids.len(),
if language.voice_forbids.len() == 1 {
""
} else {
"s"
},
language.voice_forbids.join(", ")
);
}
let _ = writeln!(
out,
" every other rule the regime states is a rewrite this engine cannot grade"
);
}
let assisted = plan.assisted();
let _ = writeln!(
out,
"\n{}",
paint(Role::Heading, "assisted fraction of this run", mode)
);
let _ = writeln!(
out,
" {} of {} — front matter {}/{}, sections {}/{}, identifier {}/{}, edge halves {}/{}",
assisted.supplied(),
assisted.total(),
assisted.fields.0,
assisted.fields.1,
assisted.sections.0,
assisted.sections.1,
assisted.identifier.0,
assisted.identifier.1,
assisted.edge_halves.0,
assisted.edge_halves.1,
);
let _ = writeln!(
out,
" It counts a section heading and never its prose, and it counts one run rather than \
this corpus. Q4 keeps `created_by` on the relation type, so no reader of a committed \
corpus can tell a scaffolded edge from a hand-typed one"
);
if recorded {
let _ = writeln!(
out,
" recorded in `{}`, which is where it trends. It names no person and no agent, \
and `headwater capture` reads it back",
headwater_scaffold::reading::STORE
);
}
let _ = writeln!(
out,
"\nRun `headwater check` over the result. Nothing this verb wrote is exempt from a rule"
);
out
}
fn capture(root: &Path, format: Option<String>) -> ExitCode {
let wants_json = match format.as_deref() {
None | Some("text") => false,
Some("json") => true,
Some(other) => {
return refuse(&format!(
"`capture --format {other}` names no target. It writes `text` and `json`"
))
}
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let (readings, unreadable) = match headwater_scaffold::reading::load(root) {
Ok(held) => held,
Err(why) => {
return refuse(&format!(
"`{}` would not read: {why}",
headwater_scaffold::reading::STORE
))
}
};
let index = headwater_graph::index::Index::build(&loaded.census, &loaded.config);
let classified = headwater_scaffold::reading::Classified {
paths: loaded
.census
.rows
.iter()
.filter(|row| matches!(row.outcome, headwater_census::census::Outcome::Typed { .. }))
.map(|row| row.path.clone())
.collect(),
identified: index
.typed
.iter()
.map(|node| (node.id.clone(), node.path.clone()))
.collect(),
};
let reach = headwater_scaffold::reading::reach(&readings, &classified);
let readings_of = |count: usize| match count {
1 => "1 reading".to_string(),
other => format!("{other} readings"),
};
let total = headwater_scaffold::reading::total(&readings);
let locks = headwater_scaffold::reading::locks(&readings);
let first = readings.iter().map(|reading| reading.date).min();
let last = readings.iter().map(|reading| reading.date).max();
if wants_json {
println!(
"{}",
headwater_scaffold::json::render(&readings, &unreadable, &classified, &reach)
);
return ExitCode::SUCCESS;
}
let mode = headwater_cli::paint::stdout_color();
println!(
"{}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Heading,
"capture-cost store",
mode
)
);
println!(
" {}, which is outside the corpus root. No census row covers it, no language regime \
binds it, and no rule reads it",
headwater_scaffold::reading::STORE
);
match first {
None => println!(
" no reading. This corpus has not run `headwater new` since the store existed, and \
every number below is empty rather than zero"
),
Some(first) => println!(
" {}, {} to {}",
readings_of(readings.len()),
first,
last.unwrap_or(first)
),
}
for line in &unreadable {
println!(
" line {} is not a reading and is counted nowhere: {}",
line.line, line.why
);
}
if !readings.is_empty() {
println!(
"\n{}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Heading,
"assisted fraction over every reading",
mode
)
);
println!(
" {} of {} — front matter {}/{}, sections {}/{}, identifier {}/{}, edge halves \
{}/{}",
total.supplied(),
total.total(),
total.fields.0,
total.fields.1,
total.sections.0,
total.sections.1,
total.identifier.0,
total.identifier.1,
total.edge_halves.0,
total.edge_halves.1,
);
println!(
" It counts a section heading and never its prose, which is the convention spec 3 \
fixes and this aggregate inherits"
);
match locks.len() {
1 => println!(
" every reading was taken under {}, so they share a denominator",
locks[0]
),
many => {
println!(
" {many} taxonomies produced these readings, so the aggregate above is \
across two denominators and is not a trend"
);
for lock in &locks {
println!(" {lock}");
}
}
}
println!(
"\n{}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Heading, "by kind", mode)
);
for (kind, taken, assisted) in headwater_scaffold::reading::by_kind(&readings) {
println!(
" {kind} — {}, {} of {}",
readings_of(taken),
assisted.supplied(),
assisted.total()
);
}
println!(
"\n{}",
headwater_cli::paint::paint(headwater_cli::paint::Role::Heading, "by surface", mode)
);
for (surface, taken, assisted) in headwater_scaffold::reading::by_surface(&readings) {
println!(
" {} — {}, {} of {}",
match surface {
Some(surface) => surface.name(),
None => "no surface stated",
},
readings_of(taken),
assisted.supplied(),
assisted.total()
);
}
if readings.iter().any(|reading| reading.surface.is_none()) {
println!(
" a reading that states no surface was taken before the term existed. It is not \
the terminal arm under another name, and this report counts it as neither"
);
}
println!(
" the surface is the entry point a run was made at, and never who drove it. A person \
at a client and an agent at the same client are one reading, which HW-OBL-0111 \
records"
);
}
println!("\nreach of the authoring verb");
println!(
" {} of {} classified documents carry a reading",
reach.reached.len(),
classified.paths.len()
);
match first {
None => println!(
" the store holds no reading, so the reach is zero by construction rather than by \
measurement"
),
Some(first) => println!(
" the first reading is dated {first}. Every document written before that day carries \
none and never could, so read this against the store's own age"
),
}
println!(
" a document written by any other route is classified here and named by no reading, so \
it lowers this number rather than being absorbed by it"
);
for (at, path) in &reach.moved {
println!(
" {} was written to {} and is now at {path}, joined by its identifier",
readings[*at].id.as_deref().unwrap_or("a reading"),
readings[*at].document
);
}
match reach.lost.is_empty() {
true => println!(" no reading names a document this corpus does not classify"),
false => {
println!(
" {} name a document this corpus does not classify, and none of them is counted \
above",
readings_of(reach.lost.len())
);
for at in &reach.lost {
println!(" {}", readings[*at].document);
}
}
}
println!("\nwhat this store does not hold, and why");
println!(
" no person and no agent. Spec 3 aims the remedy for a falling fraction at the taxonomy \
rather than at the author, and a per-author number is a performance measure"
);
println!(" no wall-clock time. A duration is not reproducible under `--now`");
println!(
" no run that refused. A refusal wrote no document, so there is nothing to attribute a \
reading to"
);
println!(
" nothing a hook or a skill did. Spec 5 says a hook binds nothing, so a disabled hook \
and a hook that stayed silent would be one reading"
);
ExitCode::SUCCESS
}
fn sweep_plan(root: &Path, under: Option<String>) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let plan = headwater_sweep::Plan::over(
&loaded.census,
&loaded.graph,
&loaded.config,
&loaded.bound.digest,
under.as_deref().unwrap_or(""),
);
print!("{}", plan.render(headwater_cli::paint::stdout_color()));
ExitCode::SUCCESS
}
fn sweep_report(root: &Path, path: &Path, format: Option<String>) -> ExitCode {
let wants_json = match format.as_deref() {
None | Some("text") => false,
Some("json") => true,
Some(other) => {
return refuse(&format!(
"`sweep report --format {other}` names no target. It writes `text` and `json`"
))
}
};
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
return fail(&format!(
"the sweep file at {} did not read: {error}",
path.display()
))
}
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let tree = headwater_sweep::Tree {
root,
census: &loaded.census,
graph: &loaded.graph,
relations: &loaded.relations,
shape: &loaded.shape,
lock: &loaded.bound.digest,
};
let report = headwater_sweep::Report::read(&source, &tree);
match wants_json {
true => println!("{}", headwater_sweep::json::render(&report)),
false => print!("{}", report.render(headwater_cli::paint::stdout_color())),
}
ExitCode::SUCCESS
}
fn probe_plan(
root: &Path,
tier: Option<&str>,
arm: Option<&str>,
category: Option<&str>,
seed: u64,
) -> ExitCode {
let tier = match tier {
None => headwater_probe::Tier::Regression,
Some(name) => match headwater_probe::Tier::read(name) {
Some(tier) => tier,
None => {
return refuse(&format!(
"`--tier {name}` names no tier. The tiers are `regression` and `campaign`"
))
}
},
};
let narrowing = headwater_probe::plan::Narrowing {
category: match category {
None => None,
Some(name) => match headwater_probe::Category::read(name) {
Some(category) => Some(category),
None => {
return refuse(&format!(
"`--category {name}` names no probe category. They are: {}",
headwater_probe::Category::ALL
.iter()
.map(|category| category.name())
.collect::<Vec<_>>()
.join(", ")
))
}
},
},
arm: match arm {
None => None,
Some(name) => match headwater_probe::Arm::read(name) {
Some(arm) => Some(arm),
None => {
return refuse(&format!(
"`--arm {name}` names no arm. The arms are `present` and `absent`"
))
}
},
},
seed,
};
let path = root.join(headwater_probe::budget::PATH);
let source = match std::fs::read_to_string(&path) {
Ok(source) => source,
Err(error) => {
return refuse(&format!(
"{} did not read: {error}. A harness with no declared ceiling cannot fail closed, \
so no run is planned without one",
headwater_probe::budget::PATH
))
}
};
let budgets = match headwater_probe::Budgets::read(&source) {
Ok(budgets) => budgets,
Err(unreadable) => return refuse(&unreadable.to_string()),
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let plan = headwater_probe::Plan::over(
&loaded.census,
&loaded.graph,
&loaded.config,
&budgets,
&loaded.bound.digest,
tier,
&narrowing,
);
print!("{}", plan.render(headwater_cli::paint::stdout_color()));
ExitCode::SUCCESS
}
fn probe_grade(root: &Path, path: &Path) -> ExitCode {
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
return fail(&format!(
"the transcript at {} did not read: {error}",
path.display()
))
}
};
let declaration = root.join(headwater_probe::budget::PATH);
let budgets = match std::fs::read_to_string(&declaration) {
Ok(source) => match headwater_probe::Budgets::read(&source) {
Ok(budgets) => budgets,
Err(unreadable) => return refuse(&unreadable.to_string()),
},
Err(error) => {
return refuse(&format!(
"`{}` did not read: {error}. A grade names the selection it was taken over, and \
the selection comes from the plan",
headwater_probe::budget::PATH
))
}
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let plan = headwater_probe::Plan::over(
&loaded.census,
&loaded.graph,
&loaded.config,
&budgets,
&loaded.bound.digest,
headwater_probe::Tier::Regression,
&headwater_probe::plan::Narrowing::default(),
);
let selected = match plan.gradable() {
Ok(selected) => selected,
Err(refusal) => {
println!("Nothing was graded. `headwater probe plan` refuses this corpus: {refusal}");
return ExitCode::SUCCESS;
}
};
let tree = headwater_probe::intake::Tree {
census: &loaded.census,
config: &loaded.config,
lock: &loaded.bound.digest,
};
let record = headwater_probe::Record::read(&source, &tree);
let results = headwater_probe::Results::over(&record, selected);
print!("{}", results.render(headwater_cli::paint::stdout_color()));
ExitCode::SUCCESS
}
fn probe_record(root: &Path, path: &Path) -> ExitCode {
let source = match std::fs::read_to_string(path) {
Ok(source) => source,
Err(error) => {
return fail(&format!(
"the transcript at {} did not read: {error}",
path.display()
))
}
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let tree = headwater_probe::intake::Tree {
census: &loaded.census,
config: &loaded.config,
lock: &loaded.bound.digest,
};
let record = headwater_probe::Record::read(&source, &tree);
print!("{}", record.render(headwater_cli::paint::stdout_color()));
ExitCode::SUCCESS
}
fn probe_stale(root: &Path) -> ExitCode {
let declaration = root.join(headwater_probe::budget::PATH);
let budgets = match std::fs::read_to_string(&declaration) {
Ok(source) => match headwater_probe::Budgets::read(&source) {
Ok(budgets) => budgets,
Err(unreadable) => return refuse(&unreadable.to_string()),
},
Err(error) => {
return refuse(&format!(
"`{}` did not read: {error}. A read set covers the probes of a selection, and the \
selection comes from the plan",
headwater_probe::budget::PATH
))
}
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let plan = headwater_probe::Plan::over(
&loaded.census,
&loaded.graph,
&loaded.config,
&budgets,
&loaded.bound.digest,
headwater_probe::Tier::Regression,
&headwater_probe::plan::Narrowing::default(),
);
if let Err(refusal) = plan.gradable() {
println!(
"No read set is composed over this corpus, so nothing here is stale or fresh: \
{refusal}"
);
return ExitCode::SUCCESS;
}
let tree = headwater_probe::intake::Tree {
census: &loaded.census,
config: &loaded.config,
lock: &loaded.bound.digest,
};
let mut seen = 0usize;
let mut stale = 0usize;
for row in &loaded.census.rows {
let headwater_census::census::Outcome::Typed { kind, .. } = &row.outcome else {
continue;
};
if kind != headwater_probe::intake::KIND {
continue;
}
seen += 1;
println!("## The result of {}", row.path);
println!();
let source = match std::fs::read_to_string(root.join(&row.path)) {
Ok(source) => source,
Err(error) => {
println!(
"The transcript did not read, so nothing here decides whether it is stale: \
{error}"
);
println!();
continue;
}
};
let record = headwater_probe::Record::read(&source, &tree);
let staleness = headwater_probe::read_set::Staleness::over(&record, &plan, &loaded.census);
if !staleness.verdict().stands() {
stale += 1;
}
print!("{}", staleness.render());
println!();
}
match seen {
0 => println!(
"This corpus holds no `{}` document, so no result has been recorded and a change \
voids nothing. A transcript is written by a recorder that observes a session from \
outside it, and no verb of this engine writes one.",
headwater_probe::intake::KIND
),
seen => println!(
"Of {}, this tree moved the read set of {}.",
headwater_probe::plural(seen, "committed transcript"),
stale
),
}
ExitCode::SUCCESS
}
fn import(root: &Path, name: Option<&str>, expect: Option<&str>, writing: bool) -> ExitCode {
let declarations = match headwater_import::declared(root) {
Ok(declarations) => declarations,
Err(why) => return refuse(&why),
};
let names: Vec<String> = declarations
.iter()
.map(|declaration| declaration.name.clone())
.collect();
let declaration = match name {
Some(name) => declarations
.iter()
.find(|declaration| declaration.name == name),
None => match declarations.len() {
1 => declarations.first(),
_ => None,
},
};
let Some(declaration) = declaration else {
return match name {
Some(name) => refuse(
&headwater_import::Refusal::Undeclared {
name: name.to_string(),
declared: names,
}
.to_string(),
),
None if names.is_empty() => refuse(
"this repository declares no import. An import is a block under `imports` in \
`.headwater/taxonomy.yml` naming where a committed snapshot sits, the digest it \
is pinned to, and the channel that digest arrived on",
),
None => fail(&format!(
"this repository declares {} imports, so `import` takes the name of one: {}",
names.len(),
names.join(", ")
)),
};
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let corpus = headwater_import::Corpus {
index: &loaded.graph.index,
relations: &loaded.relations,
shape: &loaded.shape,
};
let plan = match headwater_import::plan(root, declaration, expect, &corpus) {
Ok(plan) => plan,
Err(refusals) => {
eprintln!("headwater: {}", err("nothing was imported"));
eprint!("{}", indent(&headwater_import::render(&refusals)));
return ExitCode::FAILURE;
}
};
print!("{}", plan.render(headwater_cli::paint::stdout_color()));
let pending = plan.to_write();
if !writing {
println!(
"\n{} to write, and nothing was written. Run it again with `--write`.",
headwater_import::plural(pending.len(), "edge half", "edge halves")
);
return ExitCode::SUCCESS;
}
let composed = match headwater_import::write::compose(root, &pending) {
Ok(composed) => composed,
Err(why) => {
eprintln!("headwater: {}", err("nothing was written"));
eprintln!("{}", indent(&err(&why)));
return ExitCode::FAILURE;
}
};
if let Err(unwritten) = headwater_import::write::apply(root, &composed) {
eprintln!("headwater: {}", err(unwritten.headline()));
eprintln!("{}", indent(&err(&unwritten.to_string())));
return ExitCode::FAILURE;
}
println!(
"\nwrote {} into {}",
headwater_import::plural(pending.len(), "edge half", "edge halves"),
headwater_import::plural(composed.len(), "document", "documents")
);
println!(
"The digest says these are the bytes the pin was written for. What stands behind them is \
the channel above, and `headwater check` reads the result as it reads any other edge."
);
ExitCode::SUCCESS
}
fn generate(root: &Path, check_only: bool) -> ExitCode {
let planned = || -> Result<headwater_generate::Plan, ExitCode> {
let loaded = load(root)?;
let projections = headwater_generate::Projections::read(&loaded.bound.taxonomy)
.map_err(|errors| refused("the projections", &errors))?;
Ok(headwater_generate::plan(
&loaded.surface(),
&loaded.census,
&projections,
&loaded.identity(),
&loaded.runs(root),
headwater_verbs::VERBS,
))
};
let report = match check_only {
true => planned().map(|plan| headwater_generate::check(root, &plan)),
false => headwater_generate::write_settled(root, planned),
};
let report = match report {
Ok(report) => report,
Err(code) => return code,
};
print!("{}", report.render(headwater_cli::paint::stdout_color()));
if let Some(remedy) = report.remedy() {
eprintln!("headwater: {}", err(&remedy));
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
fn export(
root: &Path,
profile: Option<String>,
format: Option<String>,
typed: &str,
generated_at: Option<String>,
check_only: bool,
) -> ExitCode {
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let projections = match headwater_generate::Projections::read(&loaded.bound.taxonomy) {
Ok(projections) => projections,
Err(errors) => return refused("the projections", &errors),
};
let surface = loaded.surface();
let Some(target) = format else {
if generated_at.is_some() {
return fail(
"--at states the time an artifact that leaves this repository was generated, \
and it is refused for a declared output. A committed export is held to \
regeneration by byte, so a clock reading inside one would fail the gate on a \
morning when nothing changed. Name a target with --format",
);
}
let plan = match headwater_generate::export_plan(&surface, &projections, profile.as_deref())
{
Ok(plan) => plan,
Err(message) => return fail(&message),
};
let report = match check_only {
true => headwater_generate::check(root, &plan),
false => headwater_generate::write(root, &plan),
};
print!("{}", report.render(headwater_cli::paint::stdout_color()));
if let Some(producer) = report.producer {
eprintln!("headwater: {}", err(&producer.line()));
return ExitCode::FAILURE;
}
if report.has_errors() {
eprintln!(
"headwater: {}",
err("a declared export is not what this corpus and this lock produce")
);
return ExitCode::FAILURE;
}
return ExitCode::SUCCESS;
};
let Some(emitter) = headwater_generate::Emitter::parse(&target) else {
return fail(&format!(
"`{target}` is not an emitter target. Spec 6 names {}",
headwater_generate::Emitter::ALL
.iter()
.map(|one| format!("`{}`", one.name()))
.collect::<Vec<_>>()
.join(", ")
));
};
if check_only {
return fail(&format!(
"--check compares a committed artifact against what a run produces, and {typed} \
writes to standard output where nothing is committed. Run `headwater export \
--check` over the declared outputs instead"
));
}
let selected: Vec<&headwater_generate::Profile> = match &profile {
Some(name) => match projections.profile(name) {
Some(profile) => vec![profile],
None => return fail(&format!("no profile is called `{name}`")),
},
None => projections.profiles.iter().collect(),
};
let profile = match selected.as_slice() {
[one] => *one,
[] => {
return refuse(
"this taxonomy declares no projection, so it declares no export profile. \
Spec 6 makes a profile an entry under `projections`",
)
}
several => {
return fail(&format!(
"{typed} writes one artifact to standard output and this taxonomy declares {} \
profiles. Name one with --profile: {}",
several.len(),
several
.iter()
.map(|profile| format!("`{}`", profile.name))
.collect::<Vec<_>>()
.join(", ")
))
}
};
match headwater_generate::export::emit(&surface, profile, emitter, generated_at.as_deref()) {
Ok(emission) => {
print!("{}", emission.bytes);
eprint!("{}", headwater_generate::export::render(&emission.census));
if emission.census.is_defective() {
eprintln!(
"headwater: {}",
err(
"the projection census found an omission that no declared loss reason \
covers, which is a defect in this emitter rather than in the corpus"
)
);
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
Err(refusal) => {
eprintln!("headwater: {}", err("nothing was exported"));
eprintln!(" {}", refusal.reason());
ExitCode::FAILURE
}
}
}
fn mcp(root: &Path, now: Option<Date>, writing: bool) -> ExitCode {
let Some(ctx) = now.map(Context::at).or_else(Context::from_system_clock) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let now = ctx.now();
let scaffolding = move |kind: &str, title: &str, relates: &[(String, String)]| {
scaffold(
root,
kind,
title,
None,
relates,
&[],
Some(now),
EntryPoint::Protocol,
)
};
let fixing = move |format: Format| fix_over(root, &Context::at(now), format);
let server = headwater_query::mcp::Server {
surface: loaded.surface(),
census: &loaded.census,
graph: &loaded.graph,
declared: loaded.declared(),
claims: &loaded.claims,
package: &loaded.bound.package,
version: &loaded.bound.version,
now: ctx,
writing: match writing {
false => None,
true => Some(headwater_query::mcp::Writing {
scaffold: &scaffolding,
fix: &fixing,
}),
},
};
headwater_query::mcp::serve(&server, std::io::stdin().lock(), std::io::stdout().lock());
ExitCode::SUCCESS
}
fn change(root: &Path, base: &str, out: &Path) -> ExitCode {
match headwater_vcs::produce(root, base, out) {
Ok(manifest) => {
println!("{}", manifest.display());
ExitCode::SUCCESS
}
Err(message) => fail(&message),
}
}
fn gate(root: &Path, read_set: Option<PathBuf>, now: Option<Date>, json: bool) -> ExitCode {
let Some(path) = read_set else {
return fail(
"`gate` holds a read set against this tree and takes the file that carries one. \
Try `headwater check --read-set run.readset` on one tree, then `headwater gate \
--read-set run.readset` on another",
);
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => return fail(&format!("cannot read {}: {error}", path.display())),
};
let recorded = match headwater_check::Recorded::parse(&text) {
Ok(recorded) => recorded,
Err(refusal) => {
return fail(&format!(
"{} does not read as a read set. {}",
path.display(),
refusal.render()
))
}
};
let Some(asked) = now.or_else(|| Context::from_system_clock().map(|ctx| ctx.now())) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let lock = match headwater_lock::at(root) {
Ok(lock) => lock,
Err(error) => {
eprintln!("headwater: {}", err(&format!("{error}")));
return ExitCode::FAILURE;
}
};
let verdict = headwater_check::gate::decide(&recorded, &lock.digest, asked, |listed| {
if listed == headwater_check::claim::STORE {
return Some(headwater_check::claim::Claims::at(root).digest());
}
std::fs::read(root.join(listed))
.ok()
.map(|bytes| headwater_hash::digest(&bytes))
});
match json {
true => print!("{}", verdict.render_json()),
false => print!("{}", verdict.render(headwater_cli::paint::stdout_color())),
}
match verdict.carries() {
true => ExitCode::SUCCESS,
false => ExitCode::FAILURE,
}
}
fn derived(root: &Path) -> ExitCode {
let population = headwater_census::derived::population(root);
print!(
"{}",
population.render(headwater_cli::paint::stdout_color())
);
match population.agrees() {
true => ExitCode::SUCCESS,
false => ExitCode::FAILURE,
}
}
fn fix(root: &Path, ctx: &Context, cached: bool) -> Result<Fixed, ExitCode> {
let loaded = load(root)?;
let mut cache = match cached {
true => Cache::at(root, &loaded.bound.digest, &rules_digest()),
false => Cache::disabled(),
};
let run = headwater_check::run(
&loaded.census,
&loaded.graph,
&loaded.declared(),
&loaded.claims,
ctx,
&mut cache,
);
cache.write(root);
let patches: Vec<headwater_check::Patch> = run
.findings
.iter()
.filter_map(|finding| finding.patch.clone())
.collect();
let composed = headwater_scaffold::fix::compose(root, &patches);
if let Err(refusal) = headwater_scaffold::fix::apply(root, &composed.files) {
eprintln!("headwater: {}", err(&format!("{refusal}")));
return Err(ExitCode::FAILURE);
}
if let Err(refusal) = headwater_scaffold::fix::make(root, &composed.created) {
eprintln!("headwater: {}", err(&format!("{refusal}")));
return Err(ExitCode::FAILURE);
}
let mut account = String::new();
for file in &composed.files {
use std::fmt::Write;
let _ = writeln!(
account,
"headwater: fixed {} ({} patch{})",
file.path,
file.applied,
match file.applied {
1 => "",
_ => "es",
}
);
}
if !composed.created.is_empty() {
use std::fmt::Write;
let _ = writeln!(
account,
"headwater: made {} file{} under `{}`",
composed.created.len(),
match composed.created.len() {
1 => "",
_ => "s",
},
headwater_check::claim::STORE
);
}
if composed.is_empty() {
account.push_str("headwater: no finding of this run carries a patch\n");
}
Ok(Fixed {
account,
landed: !composed.files.is_empty() || !composed.created.is_empty(),
refused: composed.refused,
})
}
struct Fixed {
account: String,
landed: bool,
refused: Vec<headwater_scaffold::fix::Refused>,
}
fn refusal_account(refused: &[headwater_scaffold::fix::Refused]) -> String {
use std::fmt::Write;
if refused.is_empty() {
return String::new();
}
let mut out = String::new();
let _ = writeln!(
out,
"headwater: {} file{} refused the patch it was offered, and nothing was written to any \
of them:",
refused.len(),
match refused.len() {
1 => "",
_ => "s",
}
);
for refusal in refused {
let _ = writeln!(out, " {refusal}");
}
out
}
fn fix_over(root: &Path, ctx: &Context, format: Format) -> Result<Written, String> {
let fixed = fix(root, ctx, false).map_err(|_| {
"the fixer refused a file, and the account is on the standard error of the process \
serving this"
.to_string()
})?;
let loaded = load(root).map_err(|_| "the corpus did not load".to_string())?;
let mut cache = Cache::disabled();
let run = headwater_check::run(
&loaded.census,
&loaded.graph,
&loaded.declared(),
&loaded.claims,
ctx,
&mut cache,
);
let subject = Subject {
package: &loaded.bound.package,
version: &loaded.bound.version,
lock: &loaded.bound.digest,
now: &ctx.now().render(),
};
let artifact = headwater_adapter::render(&run, &loaded.census, &loaded.graph, &subject, format);
let audited = headwater_adapter::census(&run, format, &artifact);
if audited.is_defective() {
return Err(audited.complaint(format));
}
Ok(Written {
account: format!("{}{}", fixed.account, refusal_account(&fixed.refused)),
artifact,
landed: fixed.landed,
ok: fixed.refused.is_empty(),
})
}
struct Asked {
strict: bool,
cached: bool,
fixing: bool,
now: Option<Date>,
read_set: Option<PathBuf>,
register_out: Option<PathBuf>,
format: Option<String>,
change: Option<PathBuf>,
}
fn check(root: &Path, asked: Asked) -> ExitCode {
let Asked {
strict,
cached,
fixing,
now,
read_set,
register_out,
format,
change,
} = asked;
let format = match format.as_deref().map(Format::parse) {
None => Format::Text,
Some(Some(format)) => format,
Some(None) => {
let names: Vec<&str> = Format::ALL.iter().map(|format| format.name()).collect();
return fail(&format!(
"`check --format` takes one of {}. An emitter target of `export` is not one of \
them: that flag names a vocabulary for the graph and this one names a \
vocabulary for the findings",
names.join(", ")
));
}
};
let Some(ctx) = now.map(Context::at).or_else(Context::from_system_clock) else {
eprintln!(
"headwater: {}",
err("this host has no readable clock. Pass `--now <YYYY-MM-DD>`")
);
return ExitCode::FAILURE;
};
let unbound = match &change {
None => None,
Some(path) => match headwater_check::change::Unbound::at(path) {
Ok(unbound) => Some(unbound),
Err(why) => {
eprintln!("headwater: {}", err("the change manifest did not read"));
eprintln!("{}", indent(&err(&why)));
return ExitCode::FAILURE;
}
},
};
let refused = match fixing {
false => Vec::new(),
true => match fix(root, &ctx, cached) {
Ok(fixed) => {
eprint!("{}", fixed.account);
fixed.refused
}
Err(code) => return code,
},
};
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let Loaded {
bound,
consumer: _,
census: taken,
graph,
..
} = &loaded;
let ctx = match unbound {
None => ctx,
Some(unbound) => {
ctx.scoped_to(unbound.bind(|path| taken.rows.iter().any(|row| row.path == path)))
}
};
let mut cache = match cached {
true => Cache::at(root, &bound.digest, &rules_digest()),
false => Cache::disabled(),
};
let run = headwater_check::run(
taken,
graph,
&loaded.declared(),
&loaded.claims,
&ctx,
&mut cache,
);
cache.write(root);
let subject = Subject {
package: &bound.package,
version: &bound.version,
lock: &bound.digest,
now: &ctx.now().render(),
};
let width = headwater_cli::paint::width();
let mode = match format {
Format::Text => headwater_cli::paint::stdout_color(),
_ => headwater_cli::paint::ColorMode::Plain,
};
let artifact = headwater_adapter::render_at(&run, taken, graph, &subject, format, width, mode);
print!("{artifact}");
let audited = headwater_adapter::census(&run, format, &artifact);
if audited.is_defective() {
eprint!("headwater: {}", err(&audited.complaint(format)));
return ExitCode::FAILURE;
}
if let Some(path) = read_set {
if let Err(error) = std::fs::write(&path, run.read_set.render()) {
eprintln!(
"headwater: {}",
err(&format!("cannot write {}: {error}", path.display()))
);
return ExitCode::FAILURE;
}
}
if let Some(path) = register_out {
if let Err(error) = std::fs::write(&path, run.register.render()) {
eprintln!(
"headwater: {}",
err(&format!("cannot write {}: {error}", path.display()))
);
return ExitCode::FAILURE;
}
}
eprint!("{}", run.cache.render());
if !refused.is_empty() {
eprint!("{}", refusal_account(&refused));
return ExitCode::FAILURE;
}
if strict && run.has_errors() {
return ExitCode::FAILURE;
}
ExitCode::SUCCESS
}
const DEFAULT_WINDOW: i64 = 90;
fn infer(
root: &Path,
owner: Option<String>,
until: Option<Date>,
write: bool,
now: Option<Date>,
) -> ExitCode {
let mode = headwater_cli::paint::stdout_color();
let loaded = match load(root) {
Ok(loaded) => loaded,
Err(code) => return code,
};
let Some(ctx) = now.map(Context::at).or_else(Context::from_system_clock) else {
return refuse("the host clock is before 1970, and this engine will not guess a date");
};
let until = until.unwrap_or_else(|| ctx.now().plus_days(DEFAULT_WINDOW));
if until < ctx.now() {
return fail(&format!(
"--until {until} is in the past, and a task that has already lapsed accounts for nothing"
));
}
let declared = loaded.bound.adoption.clone();
let run = headwater_check::run(
&loaded.census,
&loaded.graph,
&Declared {
lock: &loaded.bound.digest,
taxonomy: &loaded.taxonomy,
shape: &loaded.shape,
relations: &loaded.relations,
config: &loaded.config,
register: &loaded.register,
observations: &loaded.observations,
adoption: declared.as_ref(),
source: headwater_lock::LOCK,
},
&loaded.claims,
&ctx,
&mut Cache::disabled(),
);
let mut tasks: Vec<(&'static str, Vec<&headwater_check::Finding>)> = Vec::new();
for finding in &run.findings {
match tasks.iter_mut().find(|(rule, _)| *rule == finding.rule) {
Some((_, held)) => held.push(finding),
None => tasks.push((finding.rule, vec![finding])),
}
}
let owner =
match (&owner, write) {
(Some(owner), _) => owner.clone(),
(None, true) => return fail(
"--write needs --owner. An owner is the field spec 4 ranks declared debt above a \
suppression for, and this engine will not invent one",
),
(None, false) => "TODO name a person or a team".to_string(),
};
let mut taken = claimed(declared.as_ref());
let named = taken.clone();
let standing = standing(declared.as_ref());
let mut payload = String::new();
payload.push_str("tasks:\n");
for (rule, held) in &tasks {
payload.push_str(&format!(" - id: {}\n", mint(&mut taken)));
payload.push_str(&format!(
" statement: {}\n",
quoted(&format!(
"{} {} of {rule}, {}",
held.len(),
match held.len() {
1 => "finding",
_ => "findings",
},
match standing {
0 => "raised when this taxonomy first reached this corpus",
_ => "held by no task this lock declared",
}
))
));
payload.push_str(&format!(" owner: {}\n", quoted(&owner)));
payload.push_str(&format!(" until: {until}\n"));
payload.push_str(" pairs:\n");
let mut cells: Vec<&str> = held.iter().map(|finding| finding.path.as_str()).collect();
cells.sort_unstable();
cells.dedup();
for path in cells {
payload.push_str(&format!(
" - {{path: {}, rule: {}}}\n",
quoted(path),
quoted(rule)
));
}
}
let pairs: usize = payload.matches(" - {path: ").count();
match standing {
0 => println!("the lock declares no adoption block, so this run writes the first one"),
_ => {
println!(
"the lock declares {standing} adoption {}, and a run with --write adds beside {}",
match standing {
1 => "task",
_ => "tasks",
},
match standing {
1 => "it",
_ => "them",
}
);
if !named.is_empty() {
println!(" {}", named.join(", "));
}
if named.len() < standing {
println!(
" and {} that name no `id`, which `headwater check` refuses and this run \
carries through as it found them",
standing - named.len()
);
}
}
}
match tasks.is_empty() {
true => match standing {
0 => println!("no finding, so no debt to declare"),
_ => println!(
"no finding this run raised is outside those tasks, so there is no new debt to \
declare"
),
},
false => println!(
"{pairs} pairs of debt, in {} {}, expiring {until}",
tasks.len(),
match tasks.len() {
1 => "task",
_ => "tasks",
}
),
}
let unexplained: Vec<&headwater_census::census::Row> = loaded
.census
.rows
.iter()
.filter(|row| matches!(row.outcome.class(), "untyped" | "unreadable"))
.collect();
println!(
"\n{}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Heading,
"what this taxonomy does not explain",
mode
)
);
match unexplained.is_empty() {
true => println!(" every file the census walked classified"),
false => {
println!(
" {} files classified as nothing, and no payload can hold them",
unexplained.len()
);
for row in unexplained.iter().take(10) {
println!(" {} {}", row.path, row.outcome.class());
}
if unexplained.len() > 10 {
println!(" and {} more", unexplained.len() - 10);
}
}
}
let surface = loaded.surface();
let documents = surface.documents();
let mute: Vec<&str> = documents
.iter()
.filter(|document| surface.summary(document).is_none())
.map(|document| document.path)
.collect();
println!(
"\n{}",
headwater_cli::paint::paint(
headwater_cli::paint::Role::Heading,
"what nothing will route to",
mode
)
);
if documents.is_empty() {
println!(" no document classified, so a route has nothing to reach whatever it matches");
} else {
match mute.is_empty() {
true => println!(" every classified document states a summary"),
false => println!(
" {} of {} classified documents state no summary, so a task matches them on \
their path and their anchors alone",
mute.len(),
documents.len()
),
}
}
let purposes = &surface.shape().purposes;
match purposes.is_empty() {
true => println!(
" the taxonomy declares no purposes, so a task matches nothing. `headwater init` \
writes the question that fills them in"
),
false => println!(
" {} purposes are declared, and a task is matched against their `answers` phrases",
purposes.len()
),
}
let phrases: Vec<(&str, Vec<String>)> = purposes
.iter()
.map(|purpose| {
(
purpose.name.as_str(),
headwater_query::terms(&purpose.answers.join(" ")),
)
})
.collect();
let mut mute_purposes: Vec<&str> = Vec::new();
let mut collisions: Vec<(&str, &str)> = Vec::new();
for (index, (name, terms)) in phrases.iter().enumerate() {
if terms.is_empty() {
mute_purposes.push(name);
continue;
}
for (other, others) in phrases.iter().skip(index + 1) {
if others.is_empty() {
continue;
}
let apart = terms.iter().any(|term| !others.contains(term))
|| others.iter().any(|term| !terms.contains(term));
if !apart {
collisions.push((name, other));
}
}
}
for name in &mute_purposes {
println!(
" the purpose {name} states no `answers`, so only its one-sentence intent is matched"
);
}
for (left, right) in &collisions {
println!(
" the purposes {left} and {right} answer the same terms, so no task separates them"
);
}
if !purposes.is_empty() && mute_purposes.is_empty() && collisions.is_empty() {
println!(" every purpose answers a term no other purpose answers");
}
if tasks.is_empty() {
match standing {
0 => {
println!(
"\nthis corpus raises no finding against this taxonomy, so it declares no debt"
);
if !unexplained.is_empty() {
println!(
" read that with the {} unclassified files above. A taxonomy that \
classifies nothing raises nothing",
unexplained.len()
);
}
}
_ => println!(
"\nevery finding this run raised is held by a task the lock declares, so there is \
nothing to add. {} is left as it was, with its {standing} {}",
headwater_lock::LOCK,
match standing {
1 => "task",
_ => "tasks",
}
),
}
return ExitCode::SUCCESS;
}
if !write {
println!("\nthe payload, which --write puts in the lock\n");
print!("{}", indent(&payload));
println!(
"\nRun again with --write --owner <name> to commit it. Until it is in the lock, \
`headwater check` reports every pair above as a finding"
);
return ExitCode::SUCCESS;
}
let fresh = match headwater_yaml::load(&payload) {
Ok(node) => match node.value.as_map() {
Some(map) => map.clone(),
None => {
return defect("the payload this run built is not a mapping, which is a defect")
}
},
Err(errors) => {
return defect(&format!(
"the payload this run built does not load: {}",
headwater_yaml::error::render(&errors)
))
}
};
let block = match merged(declared.as_ref(), &fresh) {
Ok(block) => block,
Err(why) => {
eprintln!(
"headwater: {}",
err(&format!(
"{} declares an adoption block this run cannot add to",
headwater_lock::LOCK
))
);
eprintln!(" {}", err(&why));
eprintln!(
" {}",
err(
"A payload written over it would discard an owner, an expiry and every \
pair, and this run cannot say what it discarded. Repair the block, or \
remove it to write a first payload"
)
);
return ExitCode::FAILURE;
}
};
let repository = match headwater_resolve::repository(root) {
Ok(repository) => repository,
Err(errors) => {
eprintln!(
"headwater: {}",
err("the taxonomy did not resolve, so no payload can be written")
);
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let sources = match headwater_resolve::package::sources(root, &repository.consumer) {
Ok(sources) => sources,
Err(errors) => {
eprint!("{}", indent(&err(&render_errors(&errors))));
return ExitCode::FAILURE;
}
};
let text = match headwater_lock::write(
&repository.consumer.package,
&repository.consumer.version,
&sources,
&repository.resolution,
Some(&block),
) {
Ok(text) => text,
Err(findings) => {
eprint!("{}", indent(&err(&render_errors(&findings))));
return ExitCode::FAILURE;
}
};
let path = root.join(headwater_lock::LOCK);
if let Err(error) = std::fs::write(&path, &text) {
return refuse(&format!("cannot write {}: {error}", path.display()));
}
println!("\nwrote the payload into {}", headwater_lock::LOCK);
println!(" {pairs} pairs, owner {owner}, until {until}");
match declared.as_ref() {
Some(block) => println!(
" {}, and added {} beside {}",
carried(block),
match tasks.len() {
1 => "1 task".to_string(),
other => format!("{other} tasks"),
},
match standing {
1 => "it",
_ => "them",
}
),
None => println!(" there was no adoption block, and this payload is the whole of it"),
}
ExitCode::SUCCESS
}
fn claimed(block: Option<&headwater_yaml::Mapping>) -> Vec<String> {
block
.and_then(|block| block.get("tasks"))
.and_then(|node| node.value.as_seq())
.map(|items| {
items
.iter()
.filter_map(|item| item.value.as_map())
.filter_map(|task| task.get("id"))
.filter_map(|node| node.value.as_scalar())
.map(|scalar| scalar.text.clone())
.collect()
})
.unwrap_or_default()
}
fn standing(block: Option<&headwater_yaml::Mapping>) -> usize {
block
.and_then(|block| block.get("tasks"))
.and_then(|node| node.value.as_seq())
.map(|items| items.len())
.unwrap_or_default()
}
fn mint(taken: &mut Vec<String>) -> String {
let mut counter = 1;
loop {
let id = format!("AD-{counter}");
if !taken.iter().any(|held| held == &id) {
taken.push(id.clone());
return id;
}
counter += 1;
}
}
fn merged(
declared: Option<&headwater_yaml::Mapping>,
fresh: &headwater_yaml::Mapping,
) -> Result<headwater_yaml::Mapping, String> {
let Some(declared) = declared else {
return Ok(fresh.clone());
};
let Some(entry) = declared.entry("tasks") else {
return Err("it declares no `tasks` key".to_string());
};
let Some(standing) = entry.value.value.as_seq() else {
return Err(format!(
"its `tasks` is {} rather than a sequence",
entry.value.value.kind_name()
));
};
let added = fresh
.get("tasks")
.and_then(|node| node.value.as_seq())
.ok_or_else(|| "the payload this run built declares no `tasks` sequence".to_string())?;
let mut items = standing.to_vec();
items.extend(added.iter().cloned());
let entries = declared
.entries()
.iter()
.map(|entry| match entry.key.value == "tasks" {
true => headwater_yaml::Entry {
key: entry.key.clone(),
value: headwater_yaml::Spanned::new(
headwater_yaml::Value::Seq(items.clone()),
entry.value.span,
),
},
false => entry.clone(),
})
.collect();
Ok(headwater_yaml::Mapping::new(entries))
}
fn migrated(
declared: Option<&headwater_yaml::Mapping>,
fresh: &headwater_yaml::Mapping,
) -> headwater_yaml::Mapping {
let mut entries: Vec<headwater_yaml::Entry> = Vec::new();
for key in ["from", "to"] {
if let Some(entry) = fresh.entry(key) {
entries.push(entry.clone());
}
}
if let Some(declared) = declared {
for entry in declared.entries() {
if entry.key.value != "from" && entry.key.value != "to" {
entries.push(entry.clone());
}
}
}
if !entries.iter().any(|entry| entry.key.value == "tasks") {
if let Some(entry) = fresh.entry("tasks") {
entries.push(entry.clone());
}
}
headwater_yaml::Mapping::new(entries)
}
fn init(
root: &Path,
corpus_root: Option<String>,
package: Option<String>,
git: bool,
git_config: bool,
) -> ExitCode {
let bound = root.join(headwater_resolve::package::CONSUMER).exists();
if !(git && bound) {
let bound = bind(root, corpus_root, package);
if bound != ExitCode::SUCCESS || !git {
return bound;
}
println!();
}
init_git(root, git_config)
}
const DRIVER_NAME: &str = "regenerate a derived artifact";
const DRIVER_LINE: &str = "headwater merge-driver %O %A %B %P";
const DRIVER_ATTRIBUTE: &str = "merge=headwater-regenerate";
const COMMITTED_ATTRIBUTE: &str = "-merge";
fn init_git(root: &Path, configure: bool) -> ExitCode {
use headwater_census::derived::{Shape, Treatment, LOCK};
let population = headwater_census::derived::population(root);
let folds: Vec<&str> = population
.members
.iter()
.filter(|member| member.shape == Shape::Fold)
.map(|member| member.path.as_str())
.collect();
let mut paths: Vec<String> = population
.outputs
.iter()
.filter(|output| folds.contains(&output.path.as_str()))
.map(|output| output.path.clone())
.collect();
paths.push(LOCK.to_string());
paths.sort();
paths.dedup();
let override_path = git_path(root, "info/attributes");
let override_text = override_path
.as_ref()
.and_then(|path| std::fs::read_to_string(path).ok())
.unwrap_or_default();
let overridden = |path: &str| {
override_text
.lines()
.any(|line| line.trim() == format!("{path} {DRIVER_ATTRIBUTE}"))
};
let root_file = headwater_census::derived::root_declarations(root);
let answers = headwater_census::derived::merge_attributes(root);
let committed = |path: &str| -> Option<Treatment> {
if let Some((_, treatment)) = root_file.iter().find(|(seen, _)| seen == path) {
return Some(*treatment);
}
answers
.iter()
.find(|(seen, _)| seen == path)
.map(|(_, treatment)| *treatment)
.filter(|treatment| !(*treatment == Treatment::Regenerate && overridden(path)))
};
let missing: Vec<&String> = paths
.iter()
.filter(|path| committed(path) != Some(Treatment::Refuse))
.collect();
let attributes = root.join(".gitattributes");
if !missing.is_empty() {
let mut text = match std::fs::read_to_string(&attributes) {
Ok(text) => text,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
Err(error) => return refuse(&format!("cannot read .gitattributes: {error}")),
};
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
let header = "# Written by `headwater init --git`. Each path is a derived artifact that holds a fold,\n\
# so a merge keeps the current side and marks it conflicted in every clone. A forge\n\
# ignores it. A clone that also ran `headwater init --git --git-config` names the verb\n\
# that rebuilds it. `headwater derived` reports a fold with no line here.\n";
if !text.contains(header) {
if !text.is_empty() {
text.push('\n');
}
text.push_str(header);
}
for path in &missing {
text.push_str(&format!("{path} {COMMITTED_ATTRIBUTE}\n"));
}
if let Err(error) = std::fs::write(&attributes, &text) {
return refuse(&format!("cannot write .gitattributes: {error}"));
}
println!("wrote .gitattributes");
for path in &missing {
println!(" {path} {COMMITTED_ATTRIBUTE}");
}
} else {
println!(
".gitattributes already declares all {} derived artifacts",
paths.len()
);
}
let lines = [
("merge.headwater-regenerate.name", DRIVER_NAME),
("merge.headwater-regenerate.driver", DRIVER_LINE),
];
let unwritten: Vec<&String> = paths.iter().filter(|path| !overridden(path)).collect();
let configured = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["config", "--get", "merge.headwater-regenerate.driver"])
.output()
.is_ok_and(|output| output.status.success() && !output.stdout.is_empty());
match configure {
false => {
println!(
"\ngit takes no merge driver from a repository, so run these once in each clone:"
);
for (key, value) in lines {
println!(" git config {key} \"{value}\"");
}
println!(
"\nand add these lines to the file `git rev-parse --git-path info/attributes` names, \
which wins over `.gitattributes` in that clone alone. Add them only with the two \
lines above, because a driver no config defines is an ordinary text merge:"
);
for path in &paths {
println!(" {path} {DRIVER_ATTRIBUTE}");
}
println!("\nor run `headwater init --git --git-config` to do both here");
if configured && !unwritten.is_empty() {
let Some(override_path) = &override_path else {
return refuse("git names no `info/attributes` path for this clone");
};
if let Err(reason) = append_override(override_path, &override_text, &unwritten) {
return refuse(&reason);
}
println!(
"\nthis clone already names the driver, so the step wrote the override to {}",
override_path.display()
);
}
}
true => {
for (key, value) in lines {
let status = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["config", key, value])
.status();
match status {
Ok(status) if status.success() => {
println!("ran git config {key} \"{value}\"");
}
Ok(status) => {
return refuse(&format!("`git config {key}` exited with {status}"));
}
Err(error) => return refuse(&format!("cannot run git: {error}")),
}
}
let Some(override_path) = override_path else {
return refuse("git names no `info/attributes` path for this clone");
};
if !unwritten.is_empty() {
if let Err(reason) = append_override(&override_path, &override_text, &unwritten) {
return refuse(&reason);
}
println!("wrote {}", override_path.display());
for path in &unwritten {
println!(" {path} {DRIVER_ATTRIBUTE}");
}
}
}
}
println!(
"\nthe driver runs `headwater`, so the binary must be on the PATH git runs with. Run the \
step again after a producer writes a new file, and `headwater derived` names any it missed"
);
ExitCode::SUCCESS
}
fn append_override(at: &Path, existing: &str, paths: &[&String]) -> Result<(), String> {
let mut text = existing.to_string();
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
for path in paths {
text.push_str(&format!("{path} {DRIVER_ATTRIBUTE}\n"));
}
if let Some(parent) = at.parent() {
std::fs::create_dir_all(parent)
.map_err(|error| format!("cannot make {}: {error}", parent.display()))?;
}
std::fs::write(at, &text).map_err(|error| format!("cannot write {}: {error}", at.display()))
}
fn git_path(root: &Path, relative: &str) -> Option<PathBuf> {
let output = std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(["rev-parse", "--git-path", relative])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = String::from_utf8(output.stdout).ok()?;
let path = PathBuf::from(text.trim_end());
Some(match path.is_absolute() {
true => path,
false => root.join(path),
})
}
fn merge_driver(current: &Path, path: &str) -> ExitCode {
let producer = match path == headwater_census::derived::LOCK {
true => "headwater taxonomy resolve",
false => "headwater generate",
};
let checked = match path == headwater_census::derived::LOCK {
true => "headwater taxonomy resolve --check",
false => "headwater generate --check",
};
let state = match current.exists() {
true => "The current side is left in place",
false => "The current side deleted it",
};
eprintln!(
"\n {path} is a derived artifact, and this merge did not reconcile it.\n\n \
It holds a fold over the whole corpus, and both sides moved it. A merge of the two\n \
would state a value true of neither tree. {state}, and the path is\n \
marked conflicted.\n\n \
Finish the merge, then rebuild it and stage it. Where the lock is conflicted too,\n \
resolve it first, because every generated file records the lock:\n\n \
{producer}\n git add {path}\n\n \
`{checked}` holds the result.\n"
);
ExitCode::FAILURE
}
fn bind(root: &Path, corpus_root: Option<String>, package: Option<String>) -> ExitCode {
let declaration = root.join(headwater_resolve::package::CONSUMER);
if declaration.exists() {
return refuse(&format!(
"{} is already there, so this repository is already bound. \
`headwater infer` is the verb that reads an existing binding",
headwater_resolve::package::CONSUMER
));
}
let proposed = corpus_root.or_else(|| busiest_directory(root));
let Some(corpus_root) = proposed else {
return refuse(
"no directory under this repository holds a Markdown file, so nothing here \
proposes a corpus root. Pass --corpus <dir> to name one",
);
};
let package = package.unwrap_or_else(|| "headwater/standard".to_string());
let found = headwater_resolve::package::find_version(root, &package);
let mut declaration_text = String::new();
declaration_text.push_str(&format!(
"\
# The consumer declaration, written by `headwater init`. It says two things,
# and they are different questions: what schema this repository takes, and what
# tree it walks.
#
# `headwater taxonomy resolve` reads this and writes {}. Everything after that
# reads the lock and never these sources.
taxonomy:
package: {package}
",
headwater_lock::LOCK
));
match &found {
Some(version) => declaration_text.push_str(&format!(" version: {version}\n")),
None => declaration_text.push_str(
" # INTERVIEW: no package of this name is under `.headwater/packages/`. Two routes\n\
\x20 # reach a lock, and each one needs a different field below.\n\
\x20 # Copy a package directory into `.headwater/packages/`, and pin `version` at\n\
\x20 # the version that package declares. Or run\n\
\x20 # `headwater taxonomy vendor <dir-or-location>` on a published artifact,\n\
\x20 # unpacked or at the `https://` location of its zip: that verb reads `digest`\n\
\x20 # and refuses until it holds the digest the publisher printed, and\n\
\x20 # `headwater taxonomy resolve` reads `version` after it, so the vendor route\n\
\x20 # needs the digest first and the version as well.\n\
\x20 # digest: sha256:<the digest the publisher printed>\n\
\x20 version: 0.0.0\n",
),
}
declaration_text.push_str(&format!(
"\
# A bundle is an optional part of the package, and a selection is add-only.
# INTERVIEW: which traditions does this corpus already follow?
bundles: []
overlay: .headwater/overlay.yml
corpus:
# Proposed from this tree: the directory holding the most Markdown.
root: {corpus_root}
# An exclusion states a reason. A pattern with none is a silent pass with a
# configuration file in front of it, so the reason is not optional.
# exclude:
# - path: {corpus_root}/vendor/**
# reason: vendored copies of documents another team owns
"
));
let overlay_text = format!(
"\
# The adopter overlay, written by `headwater init`. It is an overlay and never a
# resolved taxonomy, so nothing here can weaken the package it sits on: a
# bundle selection is add-only, and an add-only overlay carries no operation
# that removes a base rule.
#
# Every block below is a question this engine cannot answer from a tree. It is
# prose about what this corpus is for, and a corpus does not state it.
#
# INTERVIEW 1 --- what does each purpose answer?
#
# A task is matched against declared purposes before it is matched against any
# text, and it is matched on the `answers` phrases first. Two purposes whose
# phrases share every term separate nothing, and every task then matches both
# equally. Read `{package}`'s purposes, and add the phrases a person here would
# actually type.
#
# add:
# purposes.rationale.answers: [\"why is it this way\", \"what was rejected\"]
#
# INTERVIEW 2 --- what identifies a document, and what does the prefix mean?
#
# A relation names its target by identifier. A corpus whose documents carry none
# has no edges, and no check about an edge can say anything about it.
#
# `add` states a value the package leaves unstated, and `override` replaces one
# the package already states, so the operation follows the package rather than
# the taste of the writer. `{package}` declares `decision_id` with no namespace
# and gives `decision` that scheme, so the namespace below is an `add` on a leaf
# the package leaves empty, and the kind below is an `override` because `add`
# over a value the package already states is refused. Replace ACME with the
# prefix this corpus uses.
#
# add:
# identifier_schemes.doc_id: {{pattern: \"{{namespace}}-DOC-{{slug}}\", namespace: ACME, allocation: minted-once}}
# identifier_schemes.decision_id.namespace: ACME
# override:
# kinds.decision.identifier: {{scheme: doc_id}}
#
# INTERVIEW 3 --- what does this corpus already write?
#
# Run `headwater infer` once this file resolves. It reports the files that
# classify as nothing, which is the half a payload cannot carry, and the
# documents that state no summary, which nothing will route to.
add: {{}}
"
);
if let Some(parent) = declaration.parent() {
if let Err(error) = std::fs::create_dir_all(parent) {
return refuse(&format!("cannot create {}: {error}", parent.display()));
}
}
if let Err(error) = std::fs::write(&declaration, &declaration_text) {
return refuse(&format!("cannot write {}: {error}", declaration.display()));
}
let overlay = root.join(".headwater/overlay.yml");
if let Err(error) = std::fs::write(&overlay, &overlay_text) {
return refuse(&format!("cannot write {}: {error}", overlay.display()));
}
println!("wrote {}", headwater_resolve::package::CONSUMER);
println!("wrote .headwater/overlay.yml");
println!("\nwhat this read off the tree");
println!(" corpus root {corpus_root}");
match &found {
Some(version) => println!(" package {package} {version}, under `.headwater/packages/`"),
None => println!(
" package {package} is not under `.headwater/packages/`. Two routes reach a lock, and \
each one needs a different field of `.headwater/taxonomy.yml`. Copy a package \
directory into `.headwater/packages/`, and pin `taxonomy.version` at the version that \
package declares. Or run `headwater taxonomy vendor <dir-or-location>` on a published \
artifact, unpacked or at the `https://` location of its zip: that verb reads \
`taxonomy.digest` and refuses \
until it holds the digest the publisher printed, and `headwater taxonomy resolve` \
reads `taxonomy.version` after it, so the vendor route needs the digest first and \
the version as well"
),
}
println!("\nwhat it cannot read off a tree, and asked instead");
println!(" the phrases each purpose answers, which decide what a task routes to");
println!(" the identifier scheme, and what its prefix discriminates");
println!(" which bundles this corpus already follows");
println!(
"\nAnswer them in .headwater/overlay.yml, then run `headwater taxonomy resolve` and \
`headwater infer`"
);
ExitCode::SUCCESS
}
fn quoted(text: &str) -> String {
headwater_resolve::render::quoted(text)
}
fn busiest_directory(root: &Path) -> Option<String> {
let mut best: Option<(String, usize)> = None;
let entries = std::fs::read_dir(root).ok()?;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') || !entry.path().is_dir() {
continue;
}
let count = markdown_under(&entry.path(), 0);
if count == 0 {
continue;
}
if best.as_ref().is_none_or(|(_, most)| count > *most) {
best = Some((name, count));
}
}
best.map(|(name, _)| name)
}
fn markdown_under(directory: &Path, depth: usize) -> usize {
if depth > 6 {
return 0;
}
let Ok(entries) = std::fs::read_dir(directory) else {
return 0;
};
let mut count = 0;
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
count += markdown_under(&path, depth + 1);
} else if path.extension().is_some_and(|extension| extension == "md") {
count += 1;
}
}
count
}
fn indent(text: &str) -> String {
text.lines()
.map(|line| {
if line.is_empty() {
String::from("\n")
} else {
format!(" {line}\n")
}
})
.collect()
}
fn refused(what: &str, errors: &[headwater_census::shelves::DeclarationError]) -> ExitCode {
eprintln!(
"headwater: {}",
err(&format!("{what} did not read, so no run is possible"))
);
for error in errors {
eprintln!(" {}", err(&error.to_string()));
}
ExitCode::FAILURE
}
fn fail(message: &str) -> ExitCode {
for line in message.lines() {
eprintln!("headwater: {}", err(line));
}
eprintln!("headwater: run `headwater --help` for the grammar");
ExitCode::FAILURE
}
fn err(text: &str) -> String {
headwater_cli::paint::paint(
headwater_cli::paint::Role::Error,
text,
headwater_cli::paint::stderr_color(),
)
}
fn refuse(message: &str) -> ExitCode {
eprintln!("headwater: {}", err(message));
ExitCode::FAILURE
}
fn defect(message: &str) -> ExitCode {
eprintln!("headwater: {}", err(message));
eprintln!(
"headwater: {}",
err(&format!(
"this is a defect in engine {}, and neither your corpus nor your command line caused it",
headwater_resolve::release::ENGINE
))
);
ExitCode::FAILURE
}