use std::path::PathBuf;
use std::process;
use clap::{Arg, ArgAction, ArgGroup, ArgMatches, Command};
use incise_core::json::Value;
use incise_core::ops::dispatch::to_address;
use incise_core::ops::list::render_list_summary;
use incise_core::{
args, frontmatter_get, render_frontmatter, render_frontmatter_get, render_section_outline,
render_table_list, render_table_rows, table_get,
};
mod io;
mod opargs;
mod out;
mod schema;
use opargs::{Kind, FLAGS};
use out::{Format, EXIT_USAGE};
fn main() {
let matches = match cli().try_get_matches() {
Ok(m) => m,
Err(e) => e.exit(),
};
process::exit(run(&matches));
}
fn cli() -> Command {
let mut cmd = Command::new("incise")
.version(env!("CARGO_PKG_VERSION"))
.about("Content-addressed, byte-preserving markdown edits.")
.long_about(
"Content-addressed, byte-preserving markdown edits.\n\n\
Everything outside the targeted range is byte-identical after an edit. \
Addressing is semantic -- heading paths, column values, item text -- and \
never a line number, which is stale the moment anything above it moves.\n\n\
A successful edit prints one sentence saying what changed. A refusal \
prints on stderr and exits 1; it is written to be acted on, not logged.",
)
.subcommand_required(true)
.arg_required_else_help(true)
.allow_external_subcommands(true);
for op in incise_core::OPS {
cmd = cmd.subcommand(op_subcommand(op));
}
cmd.subcommand(read_subcommand("outline", "List the document's sections."))
.subcommand(read_subcommand("tables", "List the document's tables."))
.subcommand(read_subcommand("lists", "Summarize the document's lists."))
.subcommand(read_subcommand(
"front",
"Summarize the document's frontmatter.",
))
.subcommand(rows_subcommand())
.subcommand(keys_subcommand())
.subcommand(
Command::new("hash")
.about("Print the file's content hash, for --if-match.")
.arg(file_arg())
.args(common_args()),
)
.subcommand(
Command::new("schema")
.about("Print the tool schemas a model is given.")
.long_about(
"Print the tool schemas a model is given.\n\n\
These are not descriptive: each is the schema variant that won its \
measured comparison, copied from `bench/armb.py`. A harness wiring \
incise to a model should read them from here rather than keep a copy.",
)
.arg(
Arg::new("tool")
.long("tool")
.value_name("NAME")
.value_parser(schema::TOOLS.to_vec())
.help("Print one tool instead of all five"),
),
)
}
fn op_subcommand(op: &'static str) -> Command {
let mut cmd = Command::new(op)
.about(op_about(op))
.arg(file_arg())
.arg(args_arg())
.arg(args_file_arg())
.arg(
Arg::new("dry-run")
.long("dry-run")
.action(ArgAction::SetTrue)
.help("Say what would change; write nothing"),
)
.arg(
Arg::new("if-match")
.long("if-match")
.value_name("HASH")
.help("Refuse unless the file still hashes to this (any prefix)"),
)
.arg(ordinal_arg())
.args(common_args());
for flag in FLAGS {
cmd = cmd.arg(flag_arg(flag));
}
let keys: Vec<&'static str> = FLAGS
.iter()
.map(|f| f.long)
.chain(std::iter::once("ordinal"))
.collect();
cmd.group(ArgGroup::new("per-key").args(keys).multiple(true))
.mut_arg("args", |a| a.conflicts_with("per-key"))
.mut_arg("args-file", |a| a.conflicts_with("per-key"))
}
fn read_subcommand(name: &'static str, about: &'static str) -> Command {
Command::new(name)
.about(about)
.arg(file_arg())
.args(common_args())
}
fn rows_subcommand() -> Command {
read_subcommand("rows", "Show a table's rows.")
.arg(args_arg())
.arg(args_file_arg())
.arg(flag_arg(&FLAGS[0])) .arg(ordinal_arg())
.arg(
Arg::new("filter")
.long("filter")
.value_name("COLUMN=VALUE")
.action(ArgAction::Append)
.help("Show only rows whose cells match. Repeatable"),
)
.group(
ArgGroup::new("per-key")
.args(["table", "ordinal", "filter"])
.multiple(true),
)
.mut_arg("args", |a| a.conflicts_with("per-key"))
.mut_arg("args-file", |a| a.conflicts_with("per-key"))
}
fn keys_subcommand() -> Command {
read_subcommand("keys", "Show the document's frontmatter keys.")
.arg(args_arg())
.arg(args_file_arg())
.arg(flag_arg(flag_named("key")))
.group(ArgGroup::new("per-key").args(["key"]).multiple(true))
.mut_arg("args", |a| a.conflicts_with("per-key"))
.mut_arg("args-file", |a| a.conflicts_with("per-key"))
}
fn flag_named(long: &str) -> &'static opargs::Flag {
FLAGS
.iter()
.find(|f| f.long == long)
.expect("flag is declared in opargs::FLAGS")
}
fn args_arg() -> Arg {
Arg::new("args")
.long("args")
.value_name("JSON")
.help("The whole argument object, as JSON")
}
fn args_file_arg() -> Arg {
Arg::new("args-file")
.long("args-file")
.value_name("PATH")
.conflicts_with("args")
.help("The argument object from a file, or \"-\" for stdin")
}
fn file_arg() -> Arg {
Arg::new("file")
.value_name("FILE")
.help("The markdown file")
}
fn ordinal_arg() -> Arg {
Arg::new("ordinal")
.long("ordinal")
.value_name("N")
.value_parser(clap::value_parser!(i64))
.help("Which of several things sharing that heading, 0-based")
}
fn common_args() -> Vec<Arg> {
vec![
Arg::new("json")
.long("json")
.action(ArgAction::SetTrue)
.help("Machine-readable output on stdout, including refusals"),
Arg::new("quiet")
.long("quiet")
.short('q')
.action(ArgAction::SetTrue)
.help("Suppress the success line and the hash; the exit code still speaks"),
]
}
fn flag_arg(flag: &'static opargs::Flag) -> Arg {
let arg = Arg::new(flag.long).long(flag.long).help(flag.help);
match flag.kind {
Kind::Present => arg.action(ArgAction::SetTrue),
Kind::Pairs => arg.value_name(flag.value_name).action(ArgAction::Append),
Kind::Int => arg
.value_name(flag.value_name)
.value_parser(clap::value_parser!(i64)),
Kind::Bool => arg
.value_name(flag.value_name)
.value_parser(["true", "false"]),
Kind::Str | Kind::Json => arg.value_name(flag.value_name),
}
}
fn op_about(op: &'static str) -> &'static str {
match op {
"table-add-row" => "Add a row to a table.",
"table-update-cell" => "Change one cell of one row.",
"table-delete-row" => "Delete one row.",
"table-realign" => "Re-pad a table's columns.",
"list-add-item" => "Add an item to a list.",
"list-remove-item" => "Remove one item from a list.",
"list-set-checked" => "Tick or untick a checkbox item.",
"section-append" => "Add to a section's body, keeping what is there.",
"section-replace-body" => "Replace a section's body; needs --overwrite if it has one.",
"section-insert" => "Create a section, relative to an existing one.",
"section-delete" => "Delete a section and its subtree.",
"section-rename" => "Change a section's heading text.",
"section-set-level" => "Move a section to a different heading level.",
"frontmatter-set" => "Set a frontmatter key, creating it if it is absent.",
"frontmatter-delete" => "Delete a frontmatter key and anything under it.",
_ => "",
}
}
fn run(m: &ArgMatches) -> i32 {
match m.subcommand() {
Some(("outline", s)) => read(s, View::Outline),
Some(("tables", s)) => read(s, View::Tables),
Some(("lists", s)) => read(s, View::Lists),
Some(("front", s)) => read(s, View::Front),
Some(("rows", s)) => read(s, View::Rows),
Some(("keys", s)) => read(s, View::Keys),
Some(("hash", s)) => hash(s),
Some(("schema", s)) => print_schema(s),
Some((op, s)) if incise_core::OPS.contains(&op) => edit(op, s),
Some((other, _)) => {
let json = std::env::args().any(|a| a == "--json");
let f = Format { json, quiet: false };
match incise_core::apply_op("", other, None) {
Err(e) => out::refusal(&f, e.message()),
Ok(_) => EXIT_USAGE,
}
}
None => EXIT_USAGE,
}
}
fn format_of(m: &ArgMatches) -> Format {
Format {
json: m.get_flag("json"),
quiet: m.get_flag("quiet"),
}
}
fn path_of(m: &ArgMatches) -> Result<PathBuf, &'static str> {
match m.get_one::<String>("file") {
Some(p) => Ok(PathBuf::from(p)),
None => Err("no file to edit was given.\n \
`path` is the markdown file itself, not a heading path inside it.\n \
Send it, e.g. \"docs/api.md\"."),
}
}
fn edit(op: &str, m: &ArgMatches) -> i32 {
let f = format_of(m);
let path = match path_of(m) {
Ok(p) => p,
Err(msg) => return out::usage(&f, msg),
};
let args = match collect_args(m) {
Ok(v) => v,
Err(msg) => return out::usage(&f, &msg),
};
let bytes = match io::read_bytes(&path) {
Ok(b) => b,
Err(e) => return out::usage(&f, &e.0),
};
let before = match io::to_text(&bytes, &path) {
Ok(s) => s,
Err(e) => return out::usage(&f, &e.0),
};
let hash = io::sha256_hex(&bytes);
if let Some(want) = m.get_one::<String>("if-match") {
let want = want.trim().to_ascii_lowercase();
if want.is_empty() || !want.chars().all(|c| c.is_ascii_hexdigit()) {
return out::usage(&f, "--if-match takes a hex hash, or a prefix of one");
}
if !hash.starts_with(&want) {
return out::stale(&f, &want, &hash, &path);
}
}
match incise_core::apply_op(&before, op, Some(&args)) {
Err(e) => out::refusal(&f, e.message()),
Ok(after) => {
let changed = after != before;
let dry = m.get_flag("dry-run");
let written = changed && !dry;
if written {
if let Err(e) = io::write_atomic(&path, &after, &bytes) {
return out::usage(&f, &e.0);
}
}
let hash = if changed {
io::sha256_hex(after.as_bytes())
} else {
hash
};
let description = if op.starts_with("frontmatter-") {
incise_core::describe_frontmatter_change(&before, &after)
} else {
incise_core::describe_change(&before, &after)
};
out::success(&f, &description, &hash, changed, written, &path)
}
}
}
fn collect_args(m: &ArgMatches) -> Result<Value, String> {
if let Some(v) = raw_args(m)? {
return Ok(v);
}
opargs::build(
&|k| m.get_one::<String>(k).cloned(),
&|k| {
m.get_many::<String>(k)
.map(|vs| vs.cloned().collect::<Vec<_>>())
},
&|k| m.get_flag(k),
)
.map_err(|e| e.0)
}
fn raw_args(m: &ArgMatches) -> Result<Option<Value>, String> {
let raw = match (
m.get_one::<String>("args"),
m.get_one::<String>("args-file"),
) {
(Some(s), _) => s.clone(),
(None, Some(p)) => {
if p == "-" {
std::io::read_to_string(std::io::stdin())
.map_err(|e| format!("cannot read arguments from stdin: {e}"))?
} else {
std::fs::read_to_string(p).map_err(|e| format!("cannot read {p}: {e}"))?
}
}
(None, None) => return Ok(None),
};
incise_core::json::parse(raw.trim())
.map(Some)
.ok_or_else(|| format!("arguments are not valid JSON: {:?}", raw.trim()))
}
enum View {
Outline,
Tables,
Lists,
Front,
Rows,
Keys,
}
fn read(m: &ArgMatches, view: View) -> i32 {
let f = format_of(m);
let path = match path_of(m) {
Ok(p) => p,
Err(msg) => return out::usage(&f, msg),
};
let bytes = match io::read_bytes(&path) {
Ok(b) => b,
Err(e) => return out::usage(&f, &e.0),
};
let content = match io::to_text(&bytes, &path) {
Ok(s) => s,
Err(e) => return out::usage(&f, &e.0),
};
let hash = io::sha256_hex(&bytes);
let shown = path.display().to_string();
let text = match view {
View::Outline => render_section_outline(&content, &shown),
View::Tables => render_table_list(&content, &shown),
View::Lists => render_list_summary(&content, &shown),
View::Front => render_frontmatter(&content, &shown),
View::Rows => {
let args = match collect_rows_args(m) {
Ok(v) => v,
Err(msg) => return out::usage(&f, &msg),
};
let address = match args::address(&args) {
Ok(v) => to_address(v),
Err(e) => return out::refusal(&f, e.message()),
};
let got = match table_get(&content, &address, args.get("filter")) {
Ok(r) => r,
Err(e) => return out::refusal(&f, e.message()),
};
return out::rows_view(&f, &render_table_rows(&got), &got, &hash, &path);
}
View::Keys => {
let args = match collect_keys_args(m) {
Ok(v) => v,
Err(msg) => return out::usage(&f, &msg),
};
let key = args.get("key");
let got = match frontmatter_get(&content, key) {
Ok(g) => g,
Err(e) => return out::refusal(&f, e.message()),
};
let text = match render_frontmatter_get(&content, &shown, key) {
Ok(t) => t,
Err(e) => return out::refusal(&f, e.message()),
};
return out::keys_view(&f, &text, &got, &hash, &path);
}
};
out::view(&f, &text, &hash, &path)
}
fn collect_rows_args(m: &ArgMatches) -> Result<Value, String> {
if let Some(v) = raw_args(m)? {
return Ok(v);
}
let mut pairs: Vec<(String, Value)> = Vec::new();
if let Some(t) = m.get_one::<String>("table") {
let table = match m.get_one::<i64>("ordinal") {
None => Value::Str(t.clone()),
Some(n) => Value::Object(vec![
("heading".to_string(), Value::Str(t.clone())),
("ordinal".to_string(), Value::Int(*n)),
]),
};
pairs.push(("table".to_string(), table));
} else if m.get_one::<i64>("ordinal").is_some() {
return Err(
"--ordinal says which table sharing that heading, so it needs \
--table beside it."
.to_string(),
);
}
if let Some(items) = m.get_many::<String>("filter") {
let mut inner = Vec::new();
for item in items {
let (k, v) = item
.split_once('=')
.ok_or_else(|| format!("--filter takes COLUMN=VALUE, but got {item:?}"))?;
inner.push((k.to_string(), Value::Str(v.to_string())));
}
pairs.push(("filter".to_string(), Value::Object(inner)));
}
Ok(Value::Object(pairs))
}
fn collect_keys_args(m: &ArgMatches) -> Result<Value, String> {
if let Some(v) = raw_args(m)? {
return Ok(v);
}
let mut pairs: Vec<(String, Value)> = Vec::new();
if let Some(k) = m.get_one::<String>("key") {
pairs.push(("key".to_string(), Value::Str(k.clone())));
}
Ok(Value::Object(pairs))
}
fn hash(m: &ArgMatches) -> i32 {
let f = format_of(m);
let path = match path_of(m) {
Ok(p) => p,
Err(msg) => return out::usage(&f, msg),
};
match io::read_bytes(&path) {
Err(e) => out::usage(&f, &e.0),
Ok(bytes) => {
let h = io::sha256_hex(&bytes);
if f.json {
println!(
"{{\"ok\": true, \"hash\": {}, \"path\": {}}}",
incise_core::json::dumps_str(&h),
incise_core::json::dumps_str(&path.display().to_string()),
);
} else {
println!("{h}");
}
out::EXIT_OK
}
}
}
fn print_schema(m: &ArgMatches) -> i32 {
match m.get_one::<String>("tool") {
None => println!("{}", schema::SCHEMAS),
Some(name) => match schema::one(name) {
Some(one) => println!("{one}"),
None => return out::EXIT_USAGE,
},
}
out::EXIT_OK
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_command_tree_is_well_formed() {
cli().debug_assert();
}
#[test]
fn every_op_the_core_has_is_a_subcommand() {
let cmd = cli();
let names: Vec<&str> = cmd.get_subcommands().map(|s| s.get_name()).collect();
for op in incise_core::OPS {
assert!(names.contains(op), "no subcommand for {op}");
}
for name in &names {
if name.contains('-') {
assert!(incise_core::OPS.contains(name), "{name} is not an op");
}
}
}
#[test]
fn an_unknown_op_reaches_the_core_rather_than_clap() {
let e = incise_core::apply_op("", "table-add-rows", None).unwrap_err();
assert!(e
.message()
.starts_with("unknown operation \"table-add-rows\". Valid: "));
assert!(cli()
.try_get_matches_from(["incise", "table-add-rows", "x.md"])
.is_ok());
}
}