#![forbid(unsafe_code)]
#![deny(missing_docs)]
mod review_cli;
use std::collections::BTreeSet;
use std::fs;
use std::io::{self, Write as _};
use std::path::{Component, Path, PathBuf};
use std::process::{Command, ExitCode, Stdio};
use clap::{Args, Parser, Subcommand};
use sealmap::corpus::pack::{self, PackError, PackInput, PackOptions};
use sealmap::corpus::seal::{self, Lock, Resolution, SealReport, Signature};
use sealmap::corpus::{self, Drift, ExternalLanes};
use sealmap::dense::{self, DenseOptions};
use sealmap::model::{Codebase, SourcePath, SymbolId};
use sealmap::rust::{ExternalCalls, RustOptions};
use sealmap::{Options, SourceSet};
#[derive(Parser)]
#[command(name = "sealmap", version, about = "Deterministic code maps and sealed diagram contracts")]
struct Cli {
#[command(subcommand)]
cmd: Cmd,
}
#[derive(Subcommand)]
enum Cmd {
Generate(Generate),
Model(ModelArgs),
Dense(DenseArgs),
Resolve(Resolve),
SealCheck(SealCheck),
Verify(Corpus),
Stale(Stale),
#[command(subcommand)]
Seal(SealCmd),
Pack(PackArgs),
#[command(subcommand)]
Review(review_cli::Review),
}
#[derive(Subcommand)]
enum SealCmd {
Sign(Sign),
}
#[derive(Args, Clone)]
struct Source {
#[arg(long = "repo", value_name = "NAME=PATH")]
repos: Vec<String>,
#[arg(long)]
name: Option<String>,
#[arg(long)]
tests: bool,
}
#[derive(Args)]
struct Generate {
#[arg(default_value = ".")]
path: PathBuf,
#[command(flatten)]
source: Source,
#[arg(short, long, default_value = ".sealmap")]
out: PathBuf,
#[arg(long)]
check: bool,
#[arg(long, default_value = "non-std")]
external: String,
#[arg(long)]
owner_lanes: bool,
#[arg(long, default_value_t = 1)]
min_calls: usize,
#[arg(long)]
public_only: bool,
#[arg(long)]
no_model: bool,
#[arg(long)]
pretty: bool,
}
#[derive(Args)]
struct ModelArgs {
#[arg(default_value = ".")]
path: PathBuf,
#[command(flatten)]
source: Source,
#[arg(long, default_value = "non-std")]
external: String,
}
#[derive(Args)]
struct DenseArgs {
#[arg(default_value = ".")]
path: PathBuf,
#[command(flatten)]
source: Source,
#[arg(short, long, default_value = ".sealmap/dense")]
out: PathBuf,
#[arg(long, default_value_t = DenseOptions::default().max_depth)]
depth: usize,
#[arg(long, default_value = "non-std")]
external: String,
#[arg(long)]
stats: bool,
}
#[derive(Args)]
struct Site {
#[arg(long, short = 'C', default_value = ".")]
root: PathBuf,
#[arg(long, default_value = "docs/diagrams")]
diagrams: PathBuf,
#[command(flatten)]
source: Source,
}
#[derive(Args)]
struct Corpus {
#[command(flatten)]
site: Site,
#[arg(long)]
json: bool,
#[arg(long)]
all: bool,
}
#[derive(Args)]
struct Resolve {
id: String,
#[command(flatten)]
corpus: Corpus,
}
#[derive(Args)]
struct SealCheck {
topics: Vec<String>,
#[command(flatten)]
corpus: Corpus,
}
#[derive(Args)]
struct Stale {
#[arg(long, value_name = "REV")]
since: Option<String>,
#[command(flatten)]
corpus: Corpus,
}
#[derive(Args)]
struct PackArgs {
topics: Vec<String>,
#[arg(long, value_name = "REV")]
diff: Option<String>,
#[arg(long, value_name = "BYTES")]
budget: Option<usize>,
#[arg(long, default_value_t = PackOptions::default().depth)]
depth: usize,
#[arg(long, value_name = "LINES", default_value_t = PackOptions::default().source_window)]
source_window: usize,
#[arg(short, long)]
out: Option<PathBuf>,
#[arg(long, requires_all = ["budget", "out"])]
shard: bool,
#[command(flatten)]
site: Site,
#[arg(long)]
json: bool,
}
#[derive(Args)]
struct Sign {
topic: String,
#[arg(long)]
reviewer: String,
#[arg(long)]
model: String,
#[arg(long)]
date: Option<String>,
#[command(flatten)]
corpus: Corpus,
}
fn main() -> ExitCode {
match run(Cli::parse()) {
Ok(code) => code,
Err(e) => {
eprintln!("sealmap: {e}");
ExitCode::from(2)
}
}
}
fn run(cli: Cli) -> Result<ExitCode, String> {
match cli.cmd {
Cmd::Generate(g) => generate(&g),
Cmd::Model(m) => {
let (codebase, _) = extract(&m.path, &m.source, external(&m.external)?)?;
println!("{}", serde_json::to_string_pretty(&codebase).map_err(|e| e.to_string())?);
Ok(ExitCode::SUCCESS)
}
Cmd::Dense(d) => dense(&d),
Cmd::Resolve(r) => resolve(&r),
Cmd::SealCheck(s) => {
let only: BTreeSet<String> = s.topics.iter().cloned().collect();
let ctx = Ctx::load(&s.corpus.site)?;
let lock = match &ctx.lock_text {
Some(text) => Lock::parse(text).map_err(|e| lock_error(&ctx.lock_path, &e))?,
None => return Err(format!("{} does not exist", ctx.lock_path.display())),
};
let report = seal::seal_check(
&lock,
seal::LOCK_FILE,
&ctx.topics,
&ctx.codebase,
(!only.is_empty()).then_some(&only),
);
print_report(&report, &s.corpus)
}
Cmd::Verify(c) => {
let ctx = Ctx::load(&c.site)?;
let report = seal::verify(ctx.lock_text.as_deref(), seal::LOCK_FILE, &ctx.topics, &ctx.codebase);
print_report(&report, &c)
}
Cmd::Stale(s) => stale(&s),
Cmd::Seal(SealCmd::Sign(s)) => sign(&s),
Cmd::Pack(p) => pack(&p),
Cmd::Review(r) => review_cli::run(r),
}
}
fn generate(g: &Generate) -> Result<ExitCode, String> {
let output = checked_path(&g.out)?;
let mut opts = Options::default();
opts.corpus.min_calls = g.min_calls;
opts.corpus.include_private = !g.public_only;
opts.corpus.emit_model = !g.no_model;
opts.corpus.pretty_json = g.pretty;
if g.owner_lanes {
opts.corpus.external_lanes = ExternalLanes::Owner;
}
let (codebase, _) = extract(&g.path, &g.source, external(&g.external)?)?;
let corpus = sealmap::generate(&codebase, &opts.corpus);
if g.check {
let report = corpus::verify(&output, &corpus).map_err(|e| e.to_string())?;
for e in &report.entries {
println!("{:<9} {}", e.drift.to_string(), e.path);
}
if report.is_clean() {
eprintln!("sealmap: {} matches a fresh generation ({} files)", g.out.display(), report.checked);
return Ok(ExitCode::SUCCESS);
}
eprintln!("sealmap: {} files differ from a fresh generation", report.entries.len());
return Ok(ExitCode::from(1));
}
let report = corpus::write(&output, &corpus).map_err(|e| e.to_string())?;
let stats = codebase.stats();
let removed = report.count(Drift::Orphaned);
eprintln!(
"sealmap: {} files, {} symbols, {} sequences → {} ({} written, {} removed, {} unchanged)",
stats.files,
stats.symbols,
corpus.index.fragments().filter(|f| f.kind == corpus::FragmentKind::Sequence).count(),
g.out.display(),
report.entries.len() - removed,
removed,
report.checked - (report.entries.len() - removed),
);
Ok(ExitCode::SUCCESS)
}
fn checked_path(path: &Path) -> Result<PathBuf, String> {
let absolute = if path.is_absolute() {
path.to_path_buf()
} else {
std::env::current_dir().map_err(|e| e.to_string())?.join(path)
};
let mut current = PathBuf::new();
for component in absolute.components() {
match component {
Component::CurDir => continue,
Component::ParentDir => {
current.pop();
}
_ => current.push(component.as_os_str()),
}
match fs::symlink_metadata(¤t) {
Ok(meta) if meta.file_type().is_symlink() => {
return Err(format!("refusing symbolic link: {}", current.display()));
}
Ok(_) => {}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("{}: {e}", current.display())),
}
}
Ok(current)
}
fn checked_file(path: &Path) -> Result<PathBuf, String> {
let path = checked_path(path)?;
match fs::symlink_metadata(&path) {
Ok(meta) if !meta.is_file() => Err(format!("{} is not a regular file", path.display())),
Ok(_) => Ok(path),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(path),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
fn stage_file(path: &Path, text: &str) -> Result<tempfile::NamedTempFile, String> {
checked_file(path)?;
let parent = path.parent().ok_or("output file has no parent")?;
let mut file = tempfile::NamedTempFile::new_in(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
if let Ok(meta) = fs::metadata(path) {
file.as_file().set_permissions(meta.permissions()).map_err(|e| e.to_string())?;
}
file.write_all(text.as_bytes()).map_err(|e| format!("{}: {e}", path.display()))?;
Ok(file)
}
fn replace_file(file: tempfile::NamedTempFile, path: &Path) -> Result<(), String> {
checked_file(path)?;
file.persist(path).map_err(|e| format!("{}: {}", path.display(), e.error))?;
Ok(())
}
fn lock_error(path: &Path, error: &seal::LockError) -> String {
let location = match error {
seal::LockError::Syntax(message) => message
.strip_prefix("TOML parse error at line ")
.map(|rest| rest.chars().take_while(|c| c.is_ascii_digit() || ", column ".contains(*c)).collect::<String>())
.filter(|s| !s.is_empty())
.map(|s| format!(" at line {}", s.trim())),
_ => None,
};
format!("{}: invalid seal lock{} (TOML or field contract)", path.display(), location.unwrap_or_default())
}
fn dense(d: &DenseArgs) -> Result<ExitCode, String> {
let (cb, _, sources) = extract_sources(&d.path, &d.source, external(&d.external)?)?;
let out = dense::render(&cb, &DenseOptions::with_max_depth(d.depth));
let directory = checked_path(&d.out)?;
let files: Vec<_> = out.files().into_iter().map(|(name, text)| (directory.join(name), text)).collect();
for (path, _) in &files {
checked_file(path)?;
}
fs::create_dir_all(&directory).map_err(|e| format!("{}: {e}", directory.display()))?;
let staged = files.iter().map(|(path, text)| stage_file(path, text)).collect::<Result<Vec<_>, _>>()?;
for ((path, _), file) in files.into_iter().zip(staged) {
replace_file(file, &path)?;
}
if d.stats {
let source: usize = cb.files.keys().filter_map(|p| sources.get(p)).map(str::len).sum();
let calls_at = out.text.find("\n# calls\n").map_or(out.text.len(), |i| i + 1);
let stats = cb.stats();
eprintln!(
"source={source} dense={} index={} skeleton={} calls={} call_edges={} symbols={} ratio={:.3}",
out.text.len(),
out.index.len(),
calls_at,
out.text.len() - calls_at,
stats.calls,
stats.symbols,
out.text.len() as f64 / source.max(1) as f64,
);
}
Ok(ExitCode::SUCCESS)
}
fn external(s: &str) -> Result<ExternalCalls, String> {
match s {
"all" => Ok(ExternalCalls::All),
"non-std" => Ok(ExternalCalls::NonStd),
"none" => Ok(ExternalCalls::None),
other => Err(format!("--external must be all, non-std or none (got `{other}`)")),
}
}
fn extract(root: &Path, source: &Source, external_calls: ExternalCalls) -> Result<(Codebase, String), String> {
extract_sources(root, source, external_calls).map(|(cb, name, _)| (cb, name))
}
fn extract_sources(
root: &Path,
source: &Source,
external_calls: ExternalCalls,
) -> Result<(Codebase, String, SourceSet), String> {
let mut ro = RustOptions { include_tests: source.tests, external_calls, ..RustOptions::default() };
let sources: SourceSet = if source.repos.is_empty() {
ro.name = source.name.clone().unwrap_or_else(|| dir_name(root));
sealmap::rust::load_dir(root).map_err(|e| format!("{}: {e}", root.display()))?
} else {
let repos = parse_repos(&source.repos)?;
ro.name =
source.name.clone().unwrap_or_else(|| repos.iter().map(|(n, _)| n.as_str()).collect::<Vec<_>>().join("+"));
let refs: Vec<(&str, &Path)> = repos.iter().map(|(n, p)| (n.as_str(), p.as_path())).collect();
sealmap::load_repos(&refs).map_err(|e| e.to_string())?
};
let extraction = sealmap::rust::extract(&sources, &ro);
for d in &extraction.diagnostics {
eprintln!("warning: {}: {}", d.file, d.message);
}
Ok((extraction.codebase, ro.name, sources))
}
fn parse_repos(repos: &[String]) -> Result<Vec<(String, PathBuf)>, String> {
let mut result = repos
.iter()
.map(|r| {
let (name, path) = r.split_once('=').ok_or_else(|| format!("--repo expects NAME=PATH, got `{r}`"))?;
Ok((name.to_owned(), PathBuf::from(path)))
})
.collect::<Result<Vec<_>, String>>()?;
result.sort_by(|a, b| a.0.cmp(&b.0));
Ok(result)
}
fn dir_name(p: &Path) -> String {
p.canonicalize()
.ok()
.and_then(|p| p.file_name().map(|s| s.to_string_lossy().into_owned()))
.unwrap_or_else(|| "codebase".into())
}
struct Ctx {
codebase: Codebase,
sources: SourceSet,
name: String,
diagrams: PathBuf,
lock_path: PathBuf,
lock_text: Option<String>,
topics: seal::Topics,
}
impl Ctx {
fn load(c: &Site) -> Result<Self, String> {
let (codebase, name, sources) = extract_sources(&c.root, &c.source, ExternalCalls::NonStd)?;
let source_root = c.root.canonicalize().map_err(|e| format!("{}: {e}", c.root.display()))?;
let diagrams =
checked_path(&if c.diagrams.is_absolute() { c.diagrams.clone() } else { source_root.join(&c.diagrams) })?;
let lock_path = checked_file(&diagrams.join(seal::LOCK_FILE))?;
let lock_text = match fs::read_to_string(&lock_path) {
Ok(t) => Some(t),
Err(e) if e.kind() == io::ErrorKind::NotFound => None,
Err(e) => return Err(format!("{}: {e}", lock_path.display())),
};
if let Some(text) = &lock_text {
Lock::parse(text).map_err(|e| lock_error(&lock_path, &e))?;
}
let topics = read_topics(&diagrams).map_err(|e| format!("{}: {e}", diagrams.display()))?;
Ok(Self { codebase, sources, name, diagrams, lock_path, lock_text, topics })
}
fn lock(&self) -> Result<Option<Lock>, String> {
self.lock_text.as_deref().map(|text| Lock::parse(text).map_err(|e| lock_error(&self.lock_path, &e))).transpose()
}
fn model_at(&self, site: &Site, rev: &str, flag: &str) -> Result<Codebase, String> {
if !site.source.repos.is_empty() {
return Err(format!("{flag} reads one git repository; it cannot be combined with --repo"));
}
let tree = GitTree::export(&site.root, rev)?;
let source = Source { name: Some(self.name.clone()), ..site.source.clone() };
Ok(extract(tree.path(), &source, ExternalCalls::NonStd)?.0)
}
}
fn read_topics(dir: &Path) -> io::Result<seal::Topics> {
let mut out = seal::Topics::new();
if dir.is_dir() {
read_topics_rec(dir, dir, &mut out)?;
}
Ok(out)
}
fn read_topics_rec(root: &Path, dir: &Path, out: &mut seal::Topics) -> io::Result<()> {
let mut entries: Vec<_> = fs::read_dir(dir)?.collect::<Result<_, _>>()?;
entries.sort_by_key(|e| e.file_name());
for e in entries {
let path = e.path();
if e.file_name().to_string_lossy().starts_with('.') {
continue;
}
let ty = e.file_type()?;
if ty.is_dir() {
read_topics_rec(root, &path, out)?;
} else if ty.is_file() && path.extension().is_some_and(|x| x == "md") {
let text = fs::read_to_string(&path)
.map_err(|err| io::Error::new(err.kind(), format!("{}: {err}", path.display())))?;
if seal::topic_id(&text).is_some() {
let rel = SourcePath::relative_to(&path, root)
.map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err.to_string()))?;
out.insert(rel, text);
}
}
}
Ok(())
}
fn resolve(r: &Resolve) -> Result<ExitCode, String> {
let id = SymbolId::parse(&r.id).map_err(|e| format!("`{}` is not a sym: id: {e}", r.id))?;
let ctx = Ctx::load(&r.corpus.site)?;
let sealed_body = match &ctx.lock_text {
Some(text) => Lock::parse(text)
.map_err(|e| lock_error(&ctx.lock_path, &e))?
.topics
.values()
.find_map(|t| t.symbols.get(&id).map(|s| s.body)),
None => None,
};
let res = seal::resolve(&ctx.codebase, &id, sealed_body);
if r.corpus.json {
let v = serde_json::json!({ "id": id, "resolution": res });
println!("{}", serde_json::to_string_pretty(&v).map_err(|e| e.to_string())?);
} else {
match &res {
Resolution::Found { file, span, sig, body } => {
println!("found {file}:{}-{}", span.start_line, span.end_line);
println!("sig {sig}");
println!("body {body}");
}
Resolution::Unparsable { file } => println!("unparsable {file}"),
Resolution::Absent { candidates } => {
println!("absent");
for c in candidates {
println!("candidate {c}");
}
}
}
}
Ok(if matches!(res, Resolution::Found { .. }) { ExitCode::SUCCESS } else { ExitCode::from(1) })
}
fn print_report(report: &SealReport, c: &Corpus) -> Result<ExitCode, String> {
if c.json {
println!("{}", serde_json::to_string_pretty(report).map_err(|e| e.to_string())?);
} else {
for f in report.findings.iter().filter(|f| c.all || !f.class.passes()) {
println!(
"{:<17} {:<8} {} {}",
f.class.name(),
f.topic.as_deref().unwrap_or("-"),
f.symbol.as_deref().unwrap_or("-"),
f.detail
);
for cand in &f.candidates {
println!("{:<17} {:<8} {cand}", " candidate", "");
}
}
}
let failed = report.failures().count();
let topics: BTreeSet<_> = report.findings.iter().filter_map(|f| f.topic.as_deref()).collect();
eprintln!(
"sealmap: {} topic(s) checked, {} finding(s) fail, {} hold; {} topic(s) carry no seal and no citation",
topics.len(),
failed,
report.count(seal::Class::Holds),
report.unsealed_topics.len()
);
Ok(if report.passes() { ExitCode::SUCCESS } else { ExitCode::from(1) })
}
fn stale(s: &Stale) -> Result<ExitCode, String> {
let ctx = Ctx::load(&s.corpus.site)?;
let lock = ctx.lock()?.unwrap_or_default();
let before = match &s.since {
None => None,
Some(rev) => Some(ctx.model_at(&s.corpus.site, rev, "--since")?),
};
let changed = seal::stale(&lock, before.as_ref(), &ctx.codebase);
if s.corpus.json {
println!("{}", serde_json::to_string_pretty(&changed).map_err(|e| e.to_string())?);
} else {
for c in &changed {
println!("{:<8} {:<10} {} {}", c.topic, c.class.name(), c.symbol, c.detail);
for cand in &c.candidates {
println!("{:<8} {:<10} {cand}", "", " candidate");
}
}
}
let topics: BTreeSet<_> = changed.iter().map(|c| c.topic.as_str()).collect();
eprintln!(
"sealmap: {} of {} sealed topic(s) have changed symbols (since {})",
topics.len(),
lock.topics.len(),
s.since.as_deref().unwrap_or("sealing")
);
Ok(ExitCode::SUCCESS)
}
struct GitTree(tempfile::TempDir);
impl GitTree {
fn export(root: &Path, rev: &str) -> Result<Self, String> {
let resolved = git(root, &["rev-parse", "--verify", "--end-of-options", &format!("{rev}^{{commit}}")])?;
let prefix = git(root, &["rev-parse", "--show-prefix"])?;
let mut builder = tempfile::Builder::new();
builder.prefix("sealmap-since-");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
builder.permissions(fs::Permissions::from_mode(0o700));
}
let tree = Self(builder.tempdir().map_err(|e| format!("temporary git tree: {e}"))?);
let mut archive = Command::new("git")
.arg("-C")
.arg(root)
.args(["archive", "--format=tar", &format!("{}:{}", resolved.trim(), prefix.trim())])
.stdout(Stdio::piped())
.spawn()
.map_err(|e| format!("git archive: {e}"))?;
let stdout = archive.stdout.take().ok_or("git archive: no stdout")?;
let untar = Command::new("tar").arg("-x").arg("-C").arg(tree.path()).stdin(Stdio::from(stdout)).status();
let archived = archive.wait().map_err(|e| format!("git archive: {e}"))?;
let untar = untar.map_err(|e| format!("tar: {e}"))?;
if !archived.success() {
return Err(format!("git archive {rev} failed ({archived})"));
}
if !untar.success() {
return Err(format!("tar -x failed ({untar})"));
}
Ok(tree)
}
fn path(&self) -> &Path {
self.0.path()
}
}
fn git(root: &Path, args: &[&str]) -> Result<String, String> {
let out = Command::new("git").arg("-C").arg(root).args(args).output().map_err(|e| format!("git: {e}"))?;
if !out.status.success() {
return Err(format!("git {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr).trim()));
}
String::from_utf8(out.stdout).map_err(|e| format!("git {}: {e}", args.join(" ")))
}
fn sign(s: &Sign) -> Result<ExitCode, String> {
let ctx = Ctx::load(&s.corpus.site)?;
let mut lock = ctx.lock()?.unwrap_or_default();
let file = topic_file(&ctx, &s.topic)?;
let text = &ctx.topics[&file];
let who = Signature {
reviewer: s.reviewer.clone(),
model: s.model.clone(),
date: s.date.clone().unwrap_or_else(today_utc),
};
let sealed_text = match seal::sign(&mut lock, &file, text, &ctx.codebase, seal::LOCK_FILE, &who) {
Ok(t) => t,
Err(e) => {
eprintln!("sealmap: not sealed: {e}");
return Ok(ExitCode::from(1));
}
};
let topic_path = checked_file(&ctx.diagrams.join(file.as_str()))?;
checked_file(&ctx.lock_path)?;
let topic_output = if &sealed_text != text { Some(stage_file(&topic_path, &sealed_text)?) } else { None };
let lock_output = stage_file(&ctx.lock_path, &lock.to_toml())?;
checked_file(&topic_path)?;
checked_file(&ctx.lock_path)?;
replace_file(lock_output, &ctx.lock_path)?;
if let Some(output) = topic_output {
replace_file(output, &topic_path)?;
}
let entry = lock.by_file(&file).expect("sign inserted the entry");
eprintln!(
"sealmap: sealed {} ({}) with {} symbol(s) into {}",
entry.id,
file,
entry.symbols.len(),
ctx.lock_path.display()
);
Ok(ExitCode::SUCCESS)
}
fn pack(p: &PackArgs) -> Result<ExitCode, String> {
let ctx = Ctx::load(&p.site)?;
let mut ids = BTreeSet::new();
for arg in &p.topics {
let file = topic_file(&ctx, arg)?;
let id = seal::topic_id(&ctx.topics[&file]).ok_or_else(|| format!("{file} has no front-matter id"))?;
ids.insert(id);
}
if let Some(rev) = &p.diff {
let before = ctx.model_at(&p.site, rev, "--diff")?;
let files = changed_files(&p.site.root, rev)?;
let available = available_files(&p.site.root)?;
let changed = pack::changed_topics_by_files(&ctx.topics, &files, &available, &before, &ctx.codebase);
eprintln!("sealmap: {} topic(s) selected by cited files/build inputs since {rev}", changed.len());
ids.extend(changed);
let root = p.site.root.canonicalize().map_err(|e| e.to_string())?;
let diagrams = ctx.diagrams.canonicalize().map_err(|e| e.to_string())?;
for (file, text) in &ctx.topics {
let topic = diagrams.join(file.as_str());
let changed = match topic.strip_prefix(&root) {
Ok(rel) => SourcePath::new(rel.to_string_lossy()).is_ok_and(|p| files.contains(&p)),
Err(_) => true,
};
if changed {
if let Some(id) = seal::topic_id(text) {
ids.insert(id);
}
}
}
} else if ids.is_empty() {
return Err("name the topics to pack, or select them with --diff REV".into());
}
if ids.is_empty() {
if p.out.is_some() {
eprintln!("sealmap: no topics selected; no pack produced; existing output is unchanged");
return Ok(ExitCode::from(1));
}
return Ok(ExitCode::SUCCESS);
}
let ids: Vec<String> = ids.into_iter().collect();
let (revision, provenance) = pack_provenance(&p.site, &ctx)?;
let mut input = PackInput::new(&ctx.codebase, &ctx.sources, &ctx.topics, &revision).provenance(&provenance);
if let Some(rev) = &p.diff {
input = input.diff(rev);
}
let mut options = PackOptions::default().depth(p.depth).source_window(p.source_window);
if let Some(b) = p.budget {
options = options.budget(b);
}
let result = if p.shard {
pack::shard(&input, &ids, &options)
} else {
pack::pack(&input, &ids, &options).map(|one| vec![one])
};
let packs = match result {
Ok(packs) => packs,
Err(e) => {
if p.json {
println!("{}", serde_json::to_string_pretty(&e).map_err(|e| e.to_string())?);
}
eprintln!("sealmap: not packed: {e}");
if let PackError::OverBudget { header, topics, .. } = &e {
eprintln!("{:<10} {header:>10}", "header");
for t in topics {
eprintln!("{:<10} {:>10}", t.id, t.bytes);
}
}
return Ok(ExitCode::from(1));
}
};
match (&p.out, p.shard) {
(None, _) => print!("{}", packs[0].text),
(Some(file), false) => {
let file = checked_file(file)?;
let mut out = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file)
.map_err(|e| format!("{}: {e}; choose a fresh output path", file.display()))?;
out.write_all(packs[0].text.as_bytes())
.and_then(|()| out.sync_all())
.map_err(|e| format!("{}: {e}", file.display()))?;
}
(Some(dir), true) => {
let dir = checked_path(dir)?;
fs::create_dir(&dir)
.map_err(|e| format!("{}: {e}; --shard needs a fresh output directory", dir.display()))?;
let width = packs.len().to_string().len().max(2);
for (i, one) in packs.iter().enumerate() {
let file = dir.join(format!("pack-{:0width$}.txt", i + 1));
let mut out = fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&file)
.map_err(|e| format!("{}: {e}", file.display()))?;
out.write_all(one.text.as_bytes())
.and_then(|()| out.sync_all())
.map_err(|e| format!("{}: {e}", file.display()))?;
}
}
}
let bytes: usize = packs.iter().map(|one| one.text.len()).sum();
eprintln!("sealmap: packed {} topic(s) into {} pack(s), {bytes} bytes", ids.len(), packs.len());
Ok(ExitCode::SUCCESS)
}
fn available_files(root: &Path) -> Result<BTreeSet<SourcePath>, String> {
git(root, &["ls-files", "--cached", "--others", "--exclude-standard", "-z", "--", "."])?
.split('\0')
.filter(|p| !p.is_empty())
.filter(|p| fs::symlink_metadata(root.join(p)).is_ok_and(|m| m.file_type().is_file()))
.map(|p| SourcePath::new(p).map_err(|e| e.to_string()))
.collect()
}
fn changed_files(root: &Path, base: &str) -> Result<BTreeSet<SourcePath>, String> {
let base = git(root, &["rev-parse", "--verify", "--end-of-options", &format!("{base}^{{commit}}")])?;
let tracked = git(root, &["diff", "--relative", "--name-only", "--no-renames", "-z", base.trim(), "--", "."])?;
let untracked = git(root, &["ls-files", "--others", "--exclude-standard", "-z", "--", "."])?;
tracked
.split('\0')
.chain(untracked.split('\0'))
.filter(|p| !p.is_empty())
.map(|p| SourcePath::new(p).map_err(|e| e.to_string()))
.collect()
}
fn repository_provenance(root: &Path, inputs: &BTreeSet<String>) -> serde_json::Value {
let snapshot = || -> Result<serde_json::Value, String> {
let head = git(root, &["rev-parse", "--verify", "HEAD"])?;
let tracked = git(root, &["ls-files", "-z", "--", "."])?;
let tracked: BTreeSet<&str> = tracked.split('\0').filter(|p| !p.is_empty()).collect();
let changed = git(root, &["diff", "--relative", "--name-only", "--no-renames", "-z", "HEAD", "--", "."])?;
let dirty: BTreeSet<&str> = changed.split('\0').filter(|p| inputs.contains(*p)).collect();
let untracked: Vec<&str> =
inputs.iter().filter(|p| !tracked.contains(p.as_str())).map(String::as_str).collect();
Ok(serde_json::json!({"head":head.trim(), "dirty":dirty, "untracked":untracked}))
};
snapshot().unwrap_or_else(|_| serde_json::json!({"head":null,"status":"unavailable"}))
}
fn code_provenance(root: &Path, mut inputs: BTreeSet<String>) -> Result<serde_json::Value, String> {
let mut context_paths: BTreeSet<String> = [
"Cargo.lock",
"rust-toolchain",
"rust-toolchain.toml",
".cargo/config",
".cargo/config.toml",
".gitignore",
".ignore",
]
.into_iter()
.map(str::to_owned)
.collect();
for input in &inputs {
if let Some(prefix) = input.strip_suffix("Cargo.toml") {
context_paths.insert(format!("{prefix}build.rs"));
context_paths.insert(format!("{prefix}Cargo.lock"));
}
}
let mut context = serde_json::Map::new();
for name in context_paths {
match fs::read(root.join(&name)) {
Ok(bytes) => {
context.insert(name.clone(), serde_json::json!(sealmap::model::ContentHash::of_bytes(&bytes)));
inputs.insert(name);
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {}
Err(e) => return Err(format!("{}: {e}", root.join(name).display())),
}
}
let mut result = repository_provenance(root, &inputs);
result["build_context"] = serde_json::Value::Object(context);
Ok(result)
}
fn pack_provenance(site: &Site, ctx: &Ctx) -> Result<(String, String), String> {
let mut repositories = serde_json::Map::new();
if site.source.repos.is_empty() {
let inputs = ctx.sources.paths().map(|p| p.as_str().to_owned()).collect();
repositories.insert("root".into(), code_provenance(&site.root, inputs)?);
} else {
for (name, root) in parse_repos(&site.source.repos)? {
let prefix = format!("{name}/");
let inputs =
ctx.sources.paths().filter_map(|p| p.as_str().strip_prefix(&prefix).map(str::to_owned)).collect();
repositories.insert(name, code_provenance(&root, inputs)?);
}
}
let root = site.root.canonicalize().map_err(|e| e.to_string())?;
let diagrams = ctx.diagrams.canonicalize().map_err(|e| e.to_string())?;
let corpus_root = git(&diagrams, &["rev-parse", "--show-toplevel"]).ok().map(|p| PathBuf::from(p.trim()));
let corpus_base = corpus_root.as_deref().unwrap_or(&root);
let topic_inputs = ctx
.topics
.keys()
.filter_map(|p| {
diagrams.join(p.as_str()).strip_prefix(corpus_base).ok().map(|p| p.to_string_lossy().into_owned())
})
.collect();
let corpus = repository_provenance(corpus_base, &topic_inputs);
let revision = if site.source.repos.is_empty() {
let repo = &repositories["root"];
let head = repo["head"].as_str().unwrap_or("unknown");
let dirty = [&repo["dirty"], &repo["untracked"], &corpus["dirty"], &corpus["untracked"]]
.iter()
.any(|v| v.as_array().is_some_and(|a| !a.is_empty()));
format!("{head}{}", if dirty { "+dirty" } else { "" })
} else {
"multiple (see provenance)".into()
};
let provenance = serde_json::to_string(&serde_json::json!({
"capture":"working-tree", "repositories": repositories, "corpus":corpus,
"options":{"tests":site.source.tests,"external":"non-std"}
}))
.map_err(|e| e.to_string())?;
Ok((revision, provenance))
}
fn topic_file(ctx: &Ctx, arg: &str) -> Result<SourcePath, String> {
if let Some((path, _)) = ctx.topics.iter().find(|(_, t)| seal::topic_id(t).as_deref() == Some(arg)) {
return Ok(path.clone());
}
let p = Path::new(arg);
let candidates = [p.canonicalize().ok(), ctx.diagrams.join(p).canonicalize().ok()];
let diagrams = ctx.diagrams.canonicalize().map_err(|e| format!("{}: {e}", ctx.diagrams.display()))?;
for c in candidates.into_iter().flatten() {
if let Ok(rel) = SourcePath::relative_to(&c, &diagrams) {
if ctx.topics.contains_key(&rel) {
return Ok(rel);
}
}
}
Err(format!("`{arg}` is neither a topic id nor a topic file under {}", ctx.diagrams.display()))
}
fn today_utc() -> String {
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs());
civil_date((secs / 86_400) as i64)
}
fn civil_date(days: i64) -> String {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = doy - (153 * mp + 2) / 5 + 1;
let m = if mp < 10 { mp + 3 } else { mp - 9 };
let y = yoe + era * 400 + i64::from(m <= 2);
format!("{y:04}-{m:02}-{d:02}")
}
#[cfg(test)]
mod tests {
use super::civil_date;
#[test]
fn civil_dates() {
assert_eq!(civil_date(0), "1970-01-01");
assert_eq!(civil_date(11_016), "2000-02-29");
assert_eq!(civil_date(19_723), "2024-01-01");
assert_eq!(civil_date(20_731), "2026-10-05");
assert_eq!(civil_date(-1), "1969-12-31");
}
}