use std::env;
use std::fs::create_dir;
use std::path::PathBuf;
use std::process::Command as ProcessCommand;
use clap::{Args, CommandFactory, Parser, Subcommand};
use clap_complete::generate;
use clap_complete_nushell::Nushell;
mod help;
use itertools::Itertools;
use iwe::export::{dot_details_exporter, dot_exporter, graph_data};
use iwe::filter_args::FilterArgs;
use iwe::find::{DocumentFinder, FindOptions};
use iwe::new::{read_stdin_if_available, CreateOptions, DocumentCreator, IfExists};
use iwe::projection_args::{parse_projection_extend, parse_projection_replace};
use iwe::render::{FindBlockRenderer, RetrieveRenderer};
use iwe::retrieve::{DocumentReader, RetrieveOptions};
use iwe::stats::{render_stats, GraphStatistics};
use liwe::graph::{Graph, GraphContext};
use liwe::locale::get_locale;
use liwe::model::config::{load_config, ActionDefinition, Configuration, InlineType, LinkType};
use liwe::model::node::{Node, NodeIter, NodePointer, Reference, ReferenceType};
use liwe::model::tree::{Tree as ModelTree, TreeIter};
use liwe::model::Key;
use liwe::operations::{
delete as op_delete, extract as op_extract, inline as op_inline, rename as op_rename, Changes,
ExtractConfig, InlineConfig,
};
use liwe::query::{
FieldPath, Filter, Projection as QueryProjection, ProjectionSource, PseudoField,
Sort as QuerySort, SortDir,
};
use log::{debug, error, info};
const CONFIG_FILE_NAME: &str = "config.toml";
const IWE_MARKER: &str = ".iwe";
#[derive(Debug, Parser)]
#[clap(name = "iwe", version)]
pub struct App {
#[clap(flatten)]
global_opts: GlobalOpts,
#[clap(subcommand)]
command: Command,
}
#[derive(Debug, Subcommand)]
enum Command {
Init(Init),
New(New),
Retrieve(Retrieve),
Find(Find),
Count(Count),
Normalize(Normalize),
Tree(TreeArgs),
Squash(Squash),
Export(Export),
Schema(Schema),
Stats(Stats),
Rename(Rename),
Delete(Delete),
Extract(Extract),
Inline(Inline),
Update(Update),
Attach(Attach),
Completions(Completions),
}
#[derive(Debug, Args)]
#[clap(
about = help::completions::ABOUT,
long_about = help::completions::LONG_ABOUT,
after_help = help::completions::AFTER_HELP
)]
struct Completions {
#[clap(value_enum, help = "Target shell")]
shell: CompletionShell,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum CompletionShell {
Bash,
Elvish,
Fish,
Nushell,
Powershell,
Zsh,
}
#[derive(Debug, Args)]
#[clap(
about = help::retrieve::ABOUT,
long_about = help::retrieve::LONG_ABOUT,
after_help = help::retrieve::AFTER_HELP
)]
struct Retrieve {
#[clap(
long,
short = 'd',
default_value = "1",
help = "Follow block refs down N levels"
)]
depth: u8,
#[clap(
long,
short = 'c',
default_value = "1",
help = "Include N levels of parent context"
)]
context: u8,
#[clap(long, short = 'l', help = "Include inline references")]
links: bool,
#[clap(
long,
short = 'e',
help = "Exclude document key(s) from results (can be specified multiple times)"
)]
exclude: Vec<String>,
#[clap(
long,
short = 'b',
default_value_t = true,
help = "Include incoming references"
)]
backlinks: bool,
#[clap(long, short = 'f', value_enum, default_value = "markdown")]
format: RetrieveFormat,
#[clap(long, help = "Show document count and total lines without content")]
dry_run: bool,
#[clap(long, help = "Exclude document content from results (metadata only)")]
no_content: bool,
#[clap(long, help = "Populate the `includes` array with child document edges")]
children: bool,
#[clap(flatten)]
selector: FilterArgs,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum RetrieveFormat {
Markdown,
Keys,
Json,
Yaml,
}
#[derive(Debug, Args)]
struct Search {
#[clap(long, short = 'p')]
prompt: String,
}
#[derive(Debug, Args)]
#[clap(
about = help::find::ABOUT,
long_about = help::find::LONG_ABOUT,
after_help = help::find::AFTER_HELP
)]
struct Find {
#[clap(help = "Search query (fuzzy match on title and key)")]
query: Option<String>,
#[clap(long, short = 'l', help = "Maximum results (0 = unlimited)")]
limit: Option<usize>,
#[clap(
long,
value_parser = parse_projection_replace,
help = "Projection: comma-list (name, name=path, name=$selector, $selector) or inline YAML mapping. Replaces the default."
)]
project: Option<QueryProjection>,
#[clap(
long = "add-fields",
value_parser = parse_projection_extend,
conflicts_with = "project",
help = "Additive projection: same grammar as --project, extends defaults rather than replacing."
)]
add_fields: Option<QueryProjection>,
#[clap(
long,
help = "Sort by frontmatter field. Format: field:1 (asc) or field:-1 (desc)."
)]
sort: Option<String>,
#[clap(long, short = 'f', value_enum, default_value = "markdown")]
format: FindFormat,
#[clap(flatten)]
selector: FilterArgs,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum FindFormat {
Markdown,
Keys,
Json,
Yaml,
}
#[derive(Debug, Args)]
#[clap(
about = help::count::ABOUT,
long_about = help::count::LONG_ABOUT,
after_help = help::count::AFTER_HELP
)]
struct Count {
#[clap(
long,
short = 'l',
help = "Cap the number of matches counted (0 = unlimited)"
)]
limit: Option<usize>,
#[clap(flatten)]
selector: FilterArgs,
}
#[derive(Debug, Args)]
#[clap(
about = help::normalize::ABOUT,
long_about = help::normalize::LONG_ABOUT,
after_help = help::normalize::AFTER_HELP
)]
struct Normalize {}
#[derive(Debug, Args)]
#[clap(
about = help::init::ABOUT,
long_about = help::init::LONG_ABOUT,
after_help = help::init::AFTER_HELP
)]
struct Init {}
#[derive(Debug, Args)]
#[clap(
about = help::new::ABOUT,
long_about = help::new::LONG_ABOUT,
after_help = help::new::AFTER_HELP
)]
struct New {
#[clap(help = "Title for the new document")]
title: String,
#[clap(long, short = 't', help = "Template name from config")]
template: Option<String>,
#[clap(long, short = 'c', help = "Content for the new document")]
content: Option<String>,
#[clap(
long,
short = 'i',
value_enum,
default_value = "suffix",
help = "Behavior when file already exists: suffix (append -1, -2, etc.), override (overwrite), skip (do nothing)"
)]
if_exists: IfExists,
#[clap(long, short = 'e', help = "Open created file in $EDITOR")]
edit: bool,
}
#[derive(Debug, Args)]
#[clap(
about = help::tree::ABOUT,
long_about = help::tree::LONG_ABOUT,
after_help = help::tree::AFTER_HELP
)]
struct TreeArgs {
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format: markdown (nested list with links), keys, json, yaml"
)]
format: TreeFormat,
#[clap(
long,
short = 'd',
default_value = "4",
help = "Maximum depth to traverse"
)]
depth: u8,
#[clap(
long,
value_parser = parse_projection_replace,
help = "Projection: comma-list (name, name=path, name=$selector, $selector) or inline YAML mapping. Replaces user-frontmatter additions."
)]
project: Option<QueryProjection>,
#[clap(
long = "add-fields",
value_parser = parse_projection_extend,
conflicts_with = "project",
help = "Additive projection: extends each tree node's default fields. Same grammar as --project."
)]
add_fields: Option<QueryProjection>,
#[clap(flatten)]
selector: FilterArgs,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum TreeFormat {
Markdown,
Keys,
Json,
Yaml,
}
#[derive(Debug, Args)]
#[clap(
about = help::schema::ABOUT,
long_about = help::schema::LONG_ABOUT,
after_help = help::schema::AFTER_HELP
)]
struct Schema {
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format for schema"
)]
format: SchemaFormat,
#[clap(long, help = "Restrict output to a specific field (and its children)")]
field: Option<String>,
#[clap(flatten)]
selector: FilterArgs,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum SchemaFormat {
Markdown,
Json,
Yaml,
}
#[derive(Debug, Args)]
#[clap(
about = help::stats::ABOUT,
long_about = help::stats::LONG_ABOUT,
after_help = help::stats::AFTER_HELP
)]
struct Stats {
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format for statistics"
)]
format: StatsFormat,
#[clap(
long,
short = 'k',
help = "Document key for per-document stats. Omit for aggregate graph statistics."
)]
key: Option<String>,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum StatsFormat {
Markdown,
Csv,
Json,
Yaml,
}
#[derive(Debug, Args)]
#[clap(
about = help::export::ABOUT,
long_about = help::export::LONG_ABOUT,
after_help = help::export::AFTER_HELP
)]
struct Export {
#[clap(
long,
short = 'f',
value_enum,
default_value = "dot",
help = "Output format"
)]
format: Format,
#[clap(
long,
short = 'd',
global = true,
required = false,
default_value = "0"
)]
depth: u8,
#[clap(
long,
global = true,
required = false,
default_value = "false",
help = "Include section headers and create subgraphs for detailed visualization. When enabled, shows document structure with sections grouped in colored subgraphs"
)]
include_headers: bool,
#[clap(flatten)]
selector: FilterArgs,
}
#[derive(Debug, Clone, clap::ValueEnum)]
enum Format {
Dot,
}
#[derive(Debug, Clone, clap::ValueEnum, PartialEq, Eq)]
enum MutationFormat {
Markdown,
Keys,
}
#[derive(Debug, Args)]
#[clap(
about = help::squash::ABOUT,
long_about = help::squash::LONG_ABOUT,
after_help = help::squash::AFTER_HELP
)]
struct Squash {
#[clap(help = "Document key to squash")]
key: String,
#[clap(long, short, global = true, required = false, default_value = "2")]
depth: u8,
}
#[derive(Debug, Args)]
struct GlobalOpts {
#[clap(long, short, global = true, required = false, default_value = "0")]
verbose: u8,
}
#[derive(Debug, Args)]
#[clap(
about = help::rename::ABOUT,
long_about = help::rename::LONG_ABOUT,
after_help = help::rename::AFTER_HELP
)]
struct Rename {
#[clap(help = "Current document key")]
old_key: String,
#[clap(help = "New document key")]
new_key: String,
#[clap(long, help = "Preview changes without writing to disk")]
dry_run: bool,
#[clap(long, help = "Suppress progress output")]
quiet: bool,
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format. `keys` prints affected document keys (one per line) and suppresses progress."
)]
format: MutationFormat,
#[clap(long = "keys", hide = true)]
keys_legacy: bool,
}
#[derive(Debug, Args)]
#[clap(
about = help::delete::ABOUT,
long_about = help::delete::LONG_ABOUT,
after_help = help::delete::AFTER_HELP
)]
struct Delete {
#[clap(help = "Document key to delete (sugar for --filter '$key: K')")]
key: Option<String>,
#[clap(
long,
help = "Filter expression (inline YAML). Required if positional KEY omitted."
)]
filter: Option<String>,
#[clap(long, help = "Preview changes without writing to disk")]
dry_run: bool,
#[clap(long, help = "Suppress progress output")]
quiet: bool,
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format. `keys` prints affected document keys (one per line) and suppresses progress."
)]
format: MutationFormat,
#[clap(long = "keys", hide = true)]
keys_legacy: bool,
}
#[derive(Debug, Args)]
#[clap(
about = help::extract::ABOUT,
long_about = help::extract::LONG_ABOUT,
after_help = help::extract::AFTER_HELP
)]
struct Extract {
#[clap(help = "Document key containing the section to extract")]
key: String,
#[clap(
long,
help = "Section title to extract (case-insensitive)",
conflicts_with = "block"
)]
section: Option<String>,
#[clap(
long,
help = "Block number to extract (1-indexed)",
conflicts_with = "section"
)]
block: Option<usize>,
#[clap(long, help = "List all sections with block numbers")]
list: bool,
#[clap(long, help = "Action name from config to use for extraction")]
action: Option<String>,
#[clap(long, help = "Preview changes without writing to disk")]
dry_run: bool,
#[clap(long, help = "Suppress progress output")]
quiet: bool,
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format. `keys` prints affected document keys (one per line) and suppresses progress."
)]
format: MutationFormat,
#[clap(long = "keys", hide = true)]
keys_legacy: bool,
}
#[derive(Debug, Args)]
#[clap(
about = help::update::ABOUT,
long_about = help::update::LONG_ABOUT,
after_help = help::update::AFTER_HELP
)]
struct Update {
#[clap(
long,
short = 'k',
help = "Document key. Required for body-overwrite mode; optional in frontmatter mutation mode."
)]
key: Option<String>,
#[clap(
long,
short = 'c',
help = "New full markdown content (body-overwrite mode). Use '-' to read from stdin."
)]
content: Option<String>,
#[clap(
long,
help = "Filter expression for frontmatter mutation mode (inline YAML). Combined with -k via AND."
)]
filter: Option<String>,
#[clap(
long,
help = "Frontmatter $set assignment FIELD=VALUE. VALUE is parsed as a YAML scalar."
)]
set: Vec<String>,
#[clap(long, help = "Frontmatter $unset field name.")]
unset: Vec<String>,
#[clap(long, help = "Preview without writing")]
dry_run: bool,
#[clap(long, help = "Suppress progress output")]
quiet: bool,
}
#[derive(Debug, Args)]
#[clap(
about = help::attach::ABOUT,
long_about = help::attach::LONG_ABOUT,
after_help = help::attach::AFTER_HELP
)]
struct Attach {
#[clap(
long,
help = "Configured attach action(s) to attach to. Repeatable for multiple targets."
)]
to: Vec<String>,
#[clap(long, short = 'k', help = "Source document key to attach")]
key: Option<String>,
#[clap(long, help = "List configured attach actions")]
list: bool,
#[clap(long, help = "Preview without writing")]
dry_run: bool,
#[clap(long, help = "Suppress progress output")]
quiet: bool,
}
#[derive(Debug, Args)]
#[clap(
about = help::inline::ABOUT,
long_about = help::inline::LONG_ABOUT,
after_help = help::inline::AFTER_HELP
)]
struct Inline {
#[clap(help = "Document key containing the reference to inline")]
key: String,
#[clap(long, help = "Reference key or title to inline")]
reference: Option<String>,
#[clap(long, help = "Block number to inline (1-indexed)")]
block: Option<usize>,
#[clap(long, help = "List all block references with numbers")]
list: bool,
#[clap(long, help = "Action name from config to use for inlining")]
action: Option<String>,
#[clap(long, help = "Inline as blockquote instead of section")]
as_quote: bool,
#[clap(long, help = "Keep the target document after inlining")]
keep_target: bool,
#[clap(long, help = "Preview changes without writing to disk")]
dry_run: bool,
#[clap(long, help = "Suppress progress output")]
quiet: bool,
#[clap(
long,
short = 'f',
value_enum,
default_value = "markdown",
help = "Output format. `keys` prints affected document keys (one per line) and suppresses progress."
)]
format: MutationFormat,
#[clap(long = "keys", hide = true)]
keys_legacy: bool,
}
fn main() {
debug!("parsing arguments");
let app = App::parse();
if app.global_opts.verbose > 1 {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::DEBUG)
.with_writer(std::io::stderr)
.init();
} else if app.global_opts.verbose > 0 {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.with_writer(std::io::stderr)
.init();
}
debug!("starting command processing");
match app.command {
Command::Normalize(normalize) => {
normalize_command(normalize);
}
Command::Tree(tree) => {
tree_command(tree);
}
Command::Squash(squash) => {
squash_command(squash);
}
Command::Init(init) => init_command(init),
Command::New(new) => new_command(new),
Command::Retrieve(retrieve) => retrieve_command(retrieve),
Command::Find(find) => find_command(find),
Command::Count(count) => count_command(count),
Command::Export(export) => export_command(export),
Command::Schema(schema) => schema_command(schema),
Command::Stats(stats) => stats_command(stats),
Command::Rename(rename) => rename_command(rename),
Command::Delete(delete) => delete_command(delete),
Command::Extract(extract) => extract_command(extract),
Command::Inline(inline) => inline_command(inline),
Command::Update(update) => update_command(update),
Command::Attach(attach) => attach_command(attach),
Command::Completions(completions) => completions_command(completions),
}
}
fn completions_command(args: Completions) {
let mut cmd = App::command();
let bin_name = cmd.get_name().to_string();
let mut out = std::io::stdout();
match args.shell {
CompletionShell::Bash => generate(clap_complete::Shell::Bash, &mut cmd, bin_name, &mut out),
CompletionShell::Elvish => {
generate(clap_complete::Shell::Elvish, &mut cmd, bin_name, &mut out)
}
CompletionShell::Fish => generate(clap_complete::Shell::Fish, &mut cmd, bin_name, &mut out),
CompletionShell::Powershell => generate(
clap_complete::Shell::PowerShell,
&mut cmd,
bin_name,
&mut out,
),
CompletionShell::Zsh => generate(clap_complete::Shell::Zsh, &mut cmd, bin_name, &mut out),
CompletionShell::Nushell => generate(Nushell, &mut cmd, bin_name, &mut out),
}
}
#[tracing::instrument(level = "debug")]
fn retrieve_command(args: Retrieve) {
let config = get_configuration();
let graph = load_graph(&config);
let explicit_keys = args.selector.key.clone();
let other_selectors_present = args.selector.has_non_key_clauses();
let key_strings: Vec<String> = if explicit_keys.is_empty() {
let stdin_content = read_stdin_if_available();
let keys: Vec<String> = stdin_content
.lines()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if keys.is_empty() && !other_selectors_present {
eprintln!(
"Error: No document key provided. Use -k <key>, --filter, or pipe keys via stdin."
);
std::process::exit(1);
}
keys
} else {
explicit_keys
};
let mut keys = Vec::new();
for key_str in &key_strings {
let key = Key::name(key_str);
if (&graph).get_node_id(&key).is_none() {
eprintln!("Error: Document '{}' not found", key_str);
std::process::exit(1);
}
keys.push(key);
}
let reader = DocumentReader::new(&graph);
let exclude: std::collections::HashSet<Key> =
args.exclude.iter().map(|s| Key::name(s)).collect();
let options = RetrieveOptions {
depth: args.depth,
context: args.context,
links: args.links,
backlinks: args.backlinks,
exclude,
no_content: args.no_content,
children: args.children,
filter: resolve_filter(&args.selector, &graph),
};
let output = reader.retrieve_many(&keys, &options);
if args.dry_run {
let doc_count = output.documents.len();
let total_lines: usize = output
.documents
.iter()
.map(|doc| doc.content.lines().count())
.sum();
match args.format {
RetrieveFormat::Json => {
let json = serde_json::to_string_pretty(&serde_json::json!({
"documents": doc_count,
"lines": total_lines,
}))
.expect("Failed to serialize to JSON");
println!("{}", json);
}
RetrieveFormat::Yaml => {
let mut map = serde_yaml::Mapping::new();
map.insert("documents".into(), (doc_count as u64).into());
map.insert("lines".into(), (total_lines as u64).into());
let yaml = serde_yaml::to_string(&map).expect("Failed to serialize to YAML");
print!("{}", yaml);
}
_ => {
println!("documents: {}", doc_count);
println!("lines: {}", total_lines);
}
}
return;
}
match args.format {
RetrieveFormat::Json => {
let json = serde_json::to_string_pretty(&output.documents)
.expect("Failed to serialize to JSON");
println!("{}", json);
}
RetrieveFormat::Yaml => {
let yaml =
serde_yaml::to_string(&output.documents).expect("Failed to serialize to YAML");
print!("{}", yaml);
}
RetrieveFormat::Keys => {
for doc in &output.documents {
println!("{}", doc.key);
}
}
RetrieveFormat::Markdown => {
let options = graph.format_options().markdown_options();
let renderer = RetrieveRenderer::new(&output, &options, &graph);
print!("{}", renderer.render());
}
}
}
#[tracing::instrument(level = "debug")]
fn find_command(args: Find) {
let config = get_configuration();
let graph = load_graph(&config);
let sort = args
.sort
.as_deref()
.map(parse_sort_arg)
.transpose()
.unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(2);
});
let project = args.project.clone().or_else(|| args.add_fields.clone());
let finder = DocumentFinder::new(&graph);
let options = FindOptions {
query: args.query,
refs_to: None,
refs_from: None,
filter: resolve_filter(&args.selector, &graph),
limit: args.limit,
sort,
project: project.clone(),
};
let output = finder.find(&options);
match args.format {
FindFormat::Json => {
let json =
serde_json::to_string_pretty(&output.results).expect("Failed to serialize to JSON");
println!("{}", json);
}
FindFormat::Yaml => {
let yaml = serde_yaml::to_string(&output.results).expect("Failed to serialize to YAML");
print!("{}", yaml);
}
FindFormat::Keys => {
for result in &output.results {
if let Some(key) = result.get("key").and_then(|v| v.as_str()) {
println!("{}", key);
}
}
}
FindFormat::Markdown => {
let content_output_names: Vec<String> = match &project {
Some(p) => p
.fields
.iter()
.filter(|f| matches!(&f.source, ProjectionSource::Pseudo(PseudoField::Content)))
.map(|f| f.output.clone())
.collect(),
None => Vec::new(),
};
let md_options = graph.format_options().markdown_options();
let renderer = FindBlockRenderer::new(&md_options, &graph);
print!(
"{}",
renderer.render(&output.keys, &output.results, &content_output_names)
);
}
}
}
#[tracing::instrument(level = "debug")]
fn count_command(args: Count) {
use liwe::query::{execute, CountOp, Operation, Outcome};
let config = get_configuration();
let graph = load_graph(&config);
let mut op = CountOp::new();
if let Some(f) = resolve_filter(&args.selector, &graph) {
op = op.filter(f);
}
if let Some(n) = args.limit {
if n > 0 {
op = op.limit(n as u64);
}
}
match execute(&Operation::Count(op), &graph) {
Outcome::Count(n) => println!("{}", n),
_ => unreachable!(),
}
}
#[tracing::instrument(level = "debug")]
fn init_command(init: Init) {
info!("initializing IWE");
let mut path = env::current_dir().expect("to get current dir");
path.push(IWE_MARKER);
if path.is_dir() {
error!("IWE is already initialized in the current location.");
return;
}
if path.exists() {
error!("Initialization failed: '.iwe' path already exists in the current location.");
return;
}
create_dir(&path).expect("to create .iwe directory");
let toml = toml::to_string(&Configuration::template()).expect("valid TOML");
std::fs::write(path.join(CONFIG_FILE_NAME), toml).expect("Failed to write to config.json");
info!("IWE initialized in the current location. Default config added to .iwe/config.json");
}
#[tracing::instrument(level = "debug")]
fn new_command(args: New) {
let config = get_configuration();
let library_path = get_library_path(&config);
let content = args.content.or_else(|| {
let stdin_content = read_stdin_if_available();
if stdin_content.is_empty() {
None
} else {
Some(stdin_content)
}
});
let creator = DocumentCreator::new(&config, library_path);
let options = CreateOptions {
title: args.title,
template_name: args.template,
content,
if_exists: args.if_exists,
};
match creator.create(options) {
Ok(Some(doc)) => {
println!("{}", doc.path.display());
if args.edit {
open_in_editor(&doc.path);
}
}
Ok(None) => {}
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
fn open_in_editor(path: &std::path::Path) {
let editor = env::var("EDITOR").unwrap_or_else(|_| "vim".to_string());
let status = ProcessCommand::new(&editor).arg(path).status();
match status {
Ok(exit_status) => {
if !exit_status.success() {
error!("Editor exited with non-zero status");
}
}
Err(e) => {
error!("Failed to open editor '{}': {}", editor, e);
}
}
}
#[tracing::instrument(level = "debug")]
fn tree_command(args: TreeArgs) {
let config = get_configuration();
let graph = load_graph(&config);
let explicit_keys: Vec<Key> = args.selector.key.iter().map(|k| Key::name(k)).collect();
let other_selectors = args.selector.has_non_key_clauses();
let filter_for_narrowing = if other_selectors {
let mut s = args.selector.clone();
s.key.clear();
resolve_filter(&s, &graph)
} else {
None
};
let filter = filter_for_narrowing;
let root_keys: Vec<Key> = if let Some(f) = filter {
let selector_set: std::collections::HashSet<Key> =
liwe::query::evaluate(&f, &graph).into_iter().collect();
if explicit_keys.is_empty() {
let mut v: Vec<Key> = selector_set.into_iter().collect();
v.sort();
v
} else {
explicit_keys
.into_iter()
.filter(|k| selector_set.contains(k))
.collect()
}
} else if explicit_keys.is_empty() {
let paths = graph.paths();
paths
.iter()
.filter(|n| n.ids().len() == 1)
.filter_map(|n| n.first_id())
.map(|id| (&graph).node(id).node_key())
.sorted()
.unique()
.collect()
} else {
explicit_keys
};
for root_key in &root_keys {
if (&graph).get_node_id(root_key).is_none() {
eprintln!("Error: Document '{}' not found", root_key);
std::process::exit(1);
}
}
match args.format {
TreeFormat::Json | TreeFormat::Yaml => {
let project = args.project.clone().or_else(|| args.add_fields.clone());
let mut trees: Vec<serde_yaml::Mapping> = Vec::new();
for root_key in &root_keys {
let mut visited: std::collections::HashSet<Key> = std::collections::HashSet::new();
if let Some(node) =
build_tree_node(&graph, root_key, args.depth, project.as_ref(), &mut visited)
{
trees.push(node);
}
}
match args.format {
TreeFormat::Yaml => {
let yaml = serde_yaml::to_string(&trees).expect("Failed to serialize to YAML");
print!("{}", yaml);
}
_ => {
let json =
serde_json::to_string_pretty(&trees).expect("Failed to serialize to JSON");
println!("{}", json);
}
}
}
TreeFormat::Markdown | TreeFormat::Keys => {
let mut tree_lines: std::collections::BTreeMap<String, Vec<(usize, String)>> =
std::collections::BTreeMap::new();
for root_key in &root_keys {
let root_key_str = root_key.to_string();
let mut visited: std::collections::HashSet<Key> = std::collections::HashSet::new();
build_tree_lines(
&graph,
root_key,
1,
args.depth,
&args.format,
&mut visited,
&mut tree_lines,
&root_key_str,
);
}
for (_root, lines) in tree_lines {
for (depth, line) in lines {
let indent = match args.format {
TreeFormat::Markdown => " ".repeat(depth.saturating_sub(1)),
_ => "\t".repeat(depth.saturating_sub(1)),
};
let prefix = match args.format {
TreeFormat::Markdown => format!("{}- ", indent),
_ => indent,
};
println!("{}{}", prefix, line);
}
}
}
}
}
fn build_tree_node(
graph: &Graph,
key: &Key,
max_depth: u8,
project: Option<&QueryProjection>,
visited: &mut std::collections::HashSet<Key>,
) -> Option<serde_yaml::Mapping> {
use liwe::query::project::{apply_projection, ProjectionContext};
graph.get_node_id(key)?;
let title = graph.get_ref_text(key).unwrap_or_default();
let key_str = key.to_string();
let already_visited = visited.contains(key);
if !already_visited {
visited.insert(key.clone());
}
let children: Vec<serde_yaml::Mapping> = if !already_visited && max_depth > 1 {
let ref_node_ids = graph.get_inclusion_edges_in(key);
ref_node_ids
.iter()
.filter_map(|id| graph.graph_node(*id).ref_key())
.sorted()
.filter_map(|ref_key| build_tree_node(graph, &ref_key, max_depth - 1, project, visited))
.collect()
} else {
vec![]
};
let mut node = serde_yaml::Mapping::new();
node.insert(
serde_yaml::Value::from("key"),
serde_yaml::Value::from(key_str),
);
node.insert(
serde_yaml::Value::from("title"),
serde_yaml::Value::from(title),
);
if let Some(p) = project {
let ctx = ProjectionContext { graph, key };
let projected = apply_projection(&ctx, p);
for (k, v) in projected {
if let Some(s) = k.as_str() {
if matches!(s, "key" | "title" | "children") {
continue;
}
}
node.insert(k, v);
}
}
let children_value =
serde_yaml::to_value(&children).unwrap_or_else(|_| serde_yaml::Value::Sequence(Vec::new()));
node.insert(serde_yaml::Value::from("children"), children_value);
Some(node)
}
#[allow(clippy::too_many_arguments)]
fn build_tree_lines(
graph: &Graph,
key: &Key,
depth: u8,
max_depth: u8,
format: &TreeFormat,
visited: &mut std::collections::HashSet<Key>,
tree_lines: &mut std::collections::BTreeMap<String, Vec<(usize, String)>>,
root_key_str: &str,
) {
if depth > max_depth {
return;
}
if graph.get_node_id(key).is_none() {
return;
}
let line = match format {
TreeFormat::Keys => key.to_string(),
TreeFormat::Markdown => {
let text = graph.get_ref_text(key).unwrap_or_default();
format!("[{}]({})", text, key)
}
TreeFormat::Json | TreeFormat::Yaml => unreachable!(),
};
tree_lines
.entry(root_key_str.to_string())
.or_default()
.push((depth as usize, line));
if visited.contains(key) {
return;
}
visited.insert(key.clone());
let ref_node_ids = graph.get_inclusion_edges_in(key);
let ref_keys: Vec<Key> = ref_node_ids
.iter()
.filter_map(|id| graph.graph_node(*id).ref_key())
.sorted()
.collect();
for ref_key in &ref_keys {
build_tree_lines(
graph,
ref_key,
depth + 1,
max_depth,
format,
visited,
tree_lines,
root_key_str,
);
}
}
#[tracing::instrument(level = "debug")]
fn normalize_command(args: Normalize) {
let configuration = get_configuration();
let graph = load_graph(&configuration);
write_graph(graph, &configuration);
}
#[tracing::instrument(level = "debug")]
fn squash_command(args: Squash) {
let config = get_configuration();
let graph = &load_graph(&config);
let key = Key::name(&args.key);
if graph.get_node_id(&key).is_none() {
eprintln!("Error: Document '{}' not found", args.key);
std::process::exit(1);
}
let mut patch = Graph::new();
let squashed = graph.squash(&key, args.depth);
patch.build_key_from_iter(&args.key.clone().into(), TreeIter::new(&squashed));
print!("{}", patch.export_key(&args.key.into()).unwrap_or_default())
}
fn write_graph(graph: Graph, configuration: &Configuration) {
liwe::fs::write_store_at_path(
&graph.export(),
&get_library_path(configuration),
configuration.format,
)
.expect("Failed to write graph")
}
fn apply_changes(changes: &Changes, configuration: &Configuration) {
let library_path = get_library_path(configuration);
let extension = configuration.format.extension();
for key in &changes.removes {
let file_path = library_path.join(format!("{}.{}", key, extension));
if file_path.exists() {
std::fs::remove_file(&file_path).expect("Failed to delete document file");
}
let mut dir = file_path.parent().map(|p| p.to_path_buf());
while let Some(parent) = dir {
if parent == library_path || !parent.starts_with(&library_path) {
break;
}
if parent.read_dir().map_or(false, |mut d| d.next().is_none()) {
let _ = std::fs::remove_dir(&parent);
dir = parent.parent().map(|p| p.to_path_buf());
} else {
break;
}
}
}
for (key, markdown) in &changes.creates {
let file_path = library_path.join(format!("{}.{}", key, extension));
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&file_path, markdown).expect("Failed to write document file");
}
for (key, markdown) in &changes.updates {
let file_path = library_path.join(format!("{}.{}", key, extension));
std::fs::write(&file_path, markdown).expect("Failed to write document file");
}
}
fn load_graph(configuration: &Configuration) -> Graph {
Graph::from_path(
&get_library_path(configuration),
false,
configuration.format_options(),
configuration.library.frontmatter_document_title.clone(),
)
}
fn get_library_path(configuration: &Configuration) -> PathBuf {
let current_dir = env::current_dir().expect("to get current dir");
let mut library_path = current_dir;
if !configuration.library.path.is_empty() {
library_path.push(configuration.library.path.clone());
}
library_path
}
fn parse_sort_arg(s: &str) -> Result<QuerySort, String> {
let (field, dir) = s
.rsplit_once(':')
.ok_or_else(|| format!("invalid --sort value '{}': expected FIELD:1 or FIELD:-1", s))?;
let dir = match dir {
"1" => SortDir::Asc,
"-1" => SortDir::Desc,
_ => {
return Err(format!(
"invalid sort direction '{}': expected 1 or -1",
dir
))
}
};
if field.is_empty() {
return Err(format!("invalid --sort value '{}': empty field", s));
}
Ok(QuerySort {
key: FieldPath::from_dotted(field),
dir,
})
}
fn resolve_filter(args: &FilterArgs, graph: &Graph) -> Option<Filter> {
let base = args.to_filter().unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(2);
});
apply_roots(base, args.roots, graph)
}
fn apply_roots(base: Option<Filter>, roots: bool, graph: &Graph) -> Option<Filter> {
if !roots {
return base;
}
let rk: Vec<Key> = graph
.keys()
.into_iter()
.filter(|k| graph.get_inclusion_edges_to(k).is_empty())
.collect();
let roots_filter = Filter::Key(liwe::query::KeyOp::In(rk));
Some(match base {
Some(f) => Filter::And(vec![f, roots_filter]),
None => roots_filter,
})
}
fn get_configuration() -> Configuration {
let config = load_config().unwrap_or_else(|e| {
eprintln!("Error: {}", e);
std::process::exit(1);
});
if log::log_enabled!(log::Level::Debug) {
let formatted_config =
toml::to_string_pretty(&config).unwrap_or_else(|_| format!("{:#?}", config));
debug!("using config:\n{}", formatted_config);
}
config
}
fn schema_command(args: Schema) {
let config = get_configuration();
let graph = load_graph(&config);
let keys: Vec<Key> = match resolve_filter(&args.selector, &graph) {
Some(filter) => liwe::query::evaluate(&filter, &graph),
None => {
let mut k = graph.keys();
k.sort();
k
}
};
let mut fields = liwe::schema::infer_schema(&graph, &keys);
if let Some(ref field_name) = args.field {
fields.retain(|f| f.name == *field_name || f.name.starts_with(&format!("{}.", field_name)));
}
match args.format {
SchemaFormat::Json => {
let json = serde_json::to_string_pretty(&fields).expect("Failed to serialize schema");
println!("{}", json);
}
SchemaFormat::Yaml => {
let yaml = serde_yaml::to_string(&fields).expect("Failed to serialize schema");
print!("{}", yaml);
}
SchemaFormat::Markdown => {
let output = iwe::schema::render_schema(&fields);
print!("{}", output);
}
}
}
#[tracing::instrument(level = "debug")]
fn stats_command(args: Stats) {
let config = get_configuration();
let graph = load_graph(&config);
if let Some(key_str) = args.key {
let key_stats = liwe::stats::KeyStatistics::from_graph(&graph);
let entry = key_stats.into_iter().find(|s| s.key == key_str);
match entry {
Some(s) => match args.format {
StatsFormat::Markdown => {
println!("# {}\n", s.title);
println!("- **Key:** {}", s.key);
println!("- **Sections:** {}", s.sections);
println!("- **Paragraphs:** {}", s.paragraphs);
println!("- **Lines:** {}", s.lines);
println!("- **Words:** {}", s.words);
println!("- **Included by:** {}", s.included_by_count);
println!("- **Referenced by:** {}", s.referenced_by_count);
println!("- **Incoming edges:** {}", s.incoming_edges_count);
println!("- **Includes:** {}", s.includes_count);
println!("- **References:** {}", s.references_count);
println!("- **Total edges:** {}", s.total_edges_count);
println!("- **Bullet lists:** {}", s.bullet_lists);
println!("- **Ordered lists:** {}", s.ordered_lists);
println!("- **Code blocks:** {}", s.code_blocks);
println!("- **Tables:** {}", s.tables);
println!("- **Quotes:** {}", s.quotes);
}
StatsFormat::Csv => {
let stdout = std::io::stdout();
let mut csv_writer = csv::Writer::from_writer(stdout.lock());
csv_writer.serialize(&s).expect("Failed to serialize stats");
csv_writer.flush().expect("Failed to flush CSV");
}
StatsFormat::Json => {
let json = serde_json::to_string_pretty(&s).expect("Failed to serialize stats");
println!("{}", json);
}
StatsFormat::Yaml => {
let yaml = serde_yaml::to_string(&s).expect("Failed to serialize stats");
print!("{}", yaml);
}
},
None => {
eprintln!("Error: Document '{}' not found", key_str);
std::process::exit(1);
}
}
return;
}
match args.format {
StatsFormat::Markdown => {
let stats = GraphStatistics::from_graph(&graph);
let output = render_stats(&stats);
print!("{}", output);
}
StatsFormat::Csv => {
let stdout = std::io::stdout();
if let Err(e) = GraphStatistics::export_csv(&graph, stdout.lock()) {
error!("Failed to export CSV: {}", e);
std::process::exit(1);
}
}
StatsFormat::Json => {
let stats = GraphStatistics::from_graph(&graph);
let json = serde_json::to_string_pretty(&stats).expect("Failed to serialize stats");
println!("{}", json);
}
StatsFormat::Yaml => {
let stats = GraphStatistics::from_graph(&graph);
let yaml = serde_yaml::to_string(&stats).expect("Failed to serialize stats");
print!("{}", yaml);
}
}
}
#[tracing::instrument]
fn export_command(args: Export) {
let config = get_configuration();
let graph = load_graph(&config);
let explicit_keys: Vec<Key> = args.selector.key.iter().map(|s| Key::name(s)).collect();
let filter_for_narrowing = if args.selector.has_non_key_clauses() {
let mut s = args.selector.clone();
s.key.clear();
resolve_filter(&s, &graph)
} else {
None
};
let resolved_keys: Vec<Key> = if let Some(f) = filter_for_narrowing {
let selector_set: std::collections::HashSet<Key> =
liwe::query::evaluate(&f, &graph).into_iter().collect();
let mut v: Vec<Key> = if explicit_keys.is_empty() {
selector_set.into_iter().collect()
} else {
explicit_keys
.into_iter()
.filter(|k| selector_set.contains(k))
.collect()
};
v.sort();
v
} else {
explicit_keys
};
let data = graph_data::graph_data(resolved_keys, args.depth, &graph);
let output = match args.format {
Format::Dot => {
if args.include_headers {
dot_details_exporter::export_dot_with_headers(&data)
} else {
dot_exporter::export_dot(&data)
}
}
};
print!("{}", output);
}
#[tracing::instrument(level = "debug")]
fn rename_command(args: Rename) {
let config = get_configuration();
let graph = load_graph(&config);
let old_key = Key::name(&args.old_key);
let new_key = Key::name(&args.new_key);
let result = match op_rename(&graph, &old_key, &new_key) {
Ok(changes) => changes,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
let keys_mode = args.format == MutationFormat::Keys || args.keys_legacy;
if keys_mode {
for key in result.affected_keys() {
println!("{}", key);
}
if args.dry_run {
return;
}
}
if !args.quiet && !keys_mode {
if args.dry_run {
println!("Would rename '{}' to '{}'", old_key, new_key);
println!("Would update {} document(s)", result.updates.len());
for (key, _) in &result.updates {
println!(" {}", key);
}
return;
}
println!("Renaming '{}' to '{}'", old_key, new_key);
}
if !args.dry_run {
apply_changes(&result, &config);
if !args.quiet && !keys_mode {
println!("Updated {} document(s)", result.updates.len());
}
}
}
#[tracing::instrument(level = "debug")]
fn delete_command(args: Delete) {
let config = get_configuration();
let graph = load_graph(&config);
let targets = resolve_delete_targets(&args, &graph);
if targets.is_empty() {
if !args.quiet {
eprintln!("No documents matched");
}
return;
}
let mut combined = liwe::operations::Changes::default();
for target in &targets {
match op_delete(&graph, target) {
Ok(changes) => merge_changes(&mut combined, changes),
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
}
}
let keys_mode = args.format == MutationFormat::Keys || args.keys_legacy;
if keys_mode {
for key in combined.affected_keys() {
println!("{}", key);
}
if args.dry_run {
return;
}
}
if !args.quiet && !keys_mode && args.dry_run {
for target in &targets {
println!("Would delete '{}'", target);
}
println!("Would update {} document(s)", combined.updates.len());
for (key, _) in &combined.updates {
println!(" {}", key);
}
return;
}
if !args.quiet && !keys_mode {
for target in &targets {
println!("Deleting '{}'", target);
}
}
if !args.dry_run {
apply_changes(&combined, &config);
if !args.quiet && !keys_mode {
println!("Updated {} document(s)", combined.updates.len());
}
}
}
fn resolve_delete_targets(args: &Delete, graph: &Graph) -> Vec<Key> {
let mut targets: Vec<Key> = Vec::new();
if let Some(k) = &args.key {
targets.push(Key::name(k));
}
if let Some(expr) = &args.filter {
let filter = liwe::query::parse_filter_expression(expr).unwrap_or_else(|e| {
eprintln!("error: invalid --filter expression: {}", e);
std::process::exit(2);
});
let matched = liwe::query::evaluate(&filter, graph);
targets.extend(matched);
}
if targets.is_empty() {
eprintln!("Error: provide a positional KEY or --filter");
std::process::exit(1);
}
targets.sort();
targets.dedup();
targets
}
fn merge_changes(into: &mut liwe::operations::Changes, other: liwe::operations::Changes) {
for k in other.removes {
if !into.removes.contains(&k) {
into.removes.push(k);
}
}
for (k, v) in other.creates {
if !into.creates.iter().any(|(kk, _)| kk == &k) {
into.creates.push((k, v));
}
}
for (k, v) in other.updates {
if let Some(slot) = into.updates.iter_mut().find(|(kk, _)| kk == &k) {
slot.1 = v;
} else {
into.updates.push((k, v));
}
}
}
fn collect_sections(
tree: &ModelTree,
sections: &mut Vec<(usize, String, Option<liwe::model::NodeId>)>,
) {
if let Node::Section(inlines) = &tree.node {
let title = inlines.iter().map(|i| i.plain_text()).collect::<String>();
sections.push((sections.len() + 1, title, tree.id));
}
for child in &tree.children {
collect_sections(child, sections);
}
}
fn collect_inclusion_edges(
tree: &ModelTree,
refs: &mut Vec<(usize, String, Key, Option<liwe::model::NodeId>)>,
) {
if let Node::Reference(reference) = &tree.node {
refs.push((
refs.len() + 1,
reference.text.clone(),
reference.key.clone(),
tree.id,
));
}
for child in &tree.children {
collect_inclusion_edges(child, refs);
}
}
fn get_extract_config(
config: &Configuration,
action_name: Option<&str>,
) -> (String, Option<LinkType>) {
if let Some(name) = action_name {
if let Some(ActionDefinition::Extract(extract)) = config.actions.get(name) {
return (extract.key_template.clone(), extract.link_type.clone());
}
eprintln!(
"Error: Action '{}' not found or not an extract action",
name
);
std::process::exit(1);
}
for action in config.actions.values() {
if let ActionDefinition::Extract(extract) = action {
return (extract.key_template.clone(), extract.link_type.clone());
}
}
("{{slug}}".to_string(), Some(LinkType::Markdown))
}
fn get_inline_config(
config: &Configuration,
action_name: Option<&str>,
as_quote: bool,
keep_target: bool,
) -> (InlineType, bool) {
let mut inline_type = InlineType::Section;
let mut should_keep_target = false;
if let Some(name) = action_name {
if let Some(ActionDefinition::Inline(inline)) = config.actions.get(name) {
inline_type = inline.inline_type.clone();
should_keep_target = inline.keep_target.unwrap_or(false);
} else {
eprintln!("Error: Action '{}' not found or not an inline action", name);
std::process::exit(1);
}
}
if as_quote {
inline_type = InlineType::Quote;
}
if keep_target {
should_keep_target = true;
}
(inline_type, should_keep_target)
}
#[tracing::instrument(level = "debug")]
fn extract_command(args: Extract) {
let config = get_configuration();
let graph = load_graph(&config);
let source_key = Key::name(&args.key);
if (&graph).get_node_id(&source_key).is_none() {
eprintln!("Error: Document '{}' not found", args.key);
std::process::exit(1);
}
let tree = (&graph).collect(&source_key);
let mut sections: Vec<(usize, String, Option<liwe::model::NodeId>)> = Vec::new();
collect_sections(&tree, &mut sections);
if args.list {
for (num, title, _) in §ions {
println!("{}: {}", num, title);
}
return;
}
let selected_section = if let Some(ref section_title) = args.section {
let matches: Vec<_> = sections
.iter()
.filter(|(_, title, _)| title.to_lowercase().contains(§ion_title.to_lowercase()))
.collect();
if matches.is_empty() {
eprintln!("Error: No section matches '{}'", section_title);
std::process::exit(1);
} else if matches.len() > 1 {
eprintln!("Error: Multiple sections match '{}':", section_title);
for (num, title, _) in &matches {
eprintln!(" {}: {}", num, title);
}
eprintln!("Use --block <n> to select a specific section.");
std::process::exit(1);
}
matches[0].clone()
} else if let Some(block_num) = args.block {
if block_num == 0 || block_num > sections.len() {
eprintln!(
"Error: Block number {} out of range (1-{})",
block_num,
sections.len()
);
std::process::exit(1);
}
sections[block_num - 1].clone()
} else {
eprintln!("Error: Must specify --section, --block, or --list");
std::process::exit(1);
};
let (_, section_title, section_node_id) = selected_section;
let section_id = section_node_id.expect("Section must have an ID");
let (key_template, link_type) = get_extract_config(&config, args.action.as_deref());
let locale = get_locale(config.library.locale.as_deref());
let extract_config = ExtractConfig {
key_template,
link_type,
key_date_format: config
.library
.date_format
.clone()
.unwrap_or_else(|| "%Y-%m-%d".to_string()),
locale,
};
let result = match op_extract(
&graph,
&source_key,
section_id,
&extract_config,
std::time::SystemTime::now(),
) {
Ok(changes) => changes,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
let new_key = result
.creates
.first()
.map(|(k, _)| k.clone())
.expect("Extract should create a new document");
let keys_mode = args.format == MutationFormat::Keys || args.keys_legacy;
if keys_mode {
for key in result.affected_keys() {
println!("{}", key);
}
if args.dry_run {
return;
}
}
if !args.quiet && !keys_mode {
if args.dry_run {
println!("Would extract section '{}' to '{}'", section_title, new_key);
println!("Would update '{}'", source_key);
return;
}
println!("Extracting section '{}' to '{}'", section_title, new_key);
}
if !args.dry_run {
apply_changes(&result, &config);
if !args.quiet && !keys_mode {
println!("Done");
}
}
}
#[tracing::instrument(level = "debug")]
fn inline_command(args: Inline) {
let config = get_configuration();
let graph = load_graph(&config);
let source_key = Key::name(&args.key);
if (&graph).get_node_id(&source_key).is_none() {
eprintln!("Error: Document '{}' not found", args.key);
std::process::exit(1);
}
let tree = (&graph).collect(&source_key);
let mut refs: Vec<(usize, String, Key, Option<liwe::model::NodeId>)> = Vec::new();
collect_inclusion_edges(&tree, &mut refs);
if args.list {
for (num, text, key, _) in &refs {
println!("{}: [{}]({})", num, text, key);
}
return;
}
let selected_ref = if let Some(ref reference) = args.reference {
let matches: Vec<_> = refs
.iter()
.filter(|(_, text, key, _)| {
text.to_lowercase().contains(&reference.to_lowercase())
|| key
.to_string()
.to_lowercase()
.contains(&reference.to_lowercase())
})
.collect();
if matches.is_empty() {
eprintln!("Error: No reference matches '{}'", reference);
std::process::exit(1);
} else if matches.len() > 1 {
eprintln!("Error: Multiple references match '{}':", reference);
for (num, text, key, _) in &matches {
eprintln!(" {}: [{}]({})", num, text, key);
}
eprintln!("Use --block <n> to select a specific reference.");
std::process::exit(1);
}
matches[0].clone()
} else if let Some(block_num) = args.block {
if block_num == 0 || block_num > refs.len() {
eprintln!(
"Error: Block number {} out of range (1-{})",
block_num,
refs.len()
);
std::process::exit(1);
}
refs[block_num - 1].clone()
} else {
eprintln!("Error: Must specify --reference, --block, or --list");
std::process::exit(1);
};
let (_, ref_text, inline_key, ref_node_id) = selected_ref;
let ref_id = ref_node_id.expect("Reference must have an ID");
let (inline_type, should_keep_target) = get_inline_config(
&config,
args.action.as_deref(),
args.as_quote,
args.keep_target,
);
let inline_config = InlineConfig {
inline_type,
keep_target: should_keep_target,
};
let result = match op_inline(&graph, &source_key, ref_id, &inline_config) {
Ok(changes) => changes,
Err(e) => {
eprintln!("Error: {}", e);
std::process::exit(1);
}
};
let keys_mode = args.format == MutationFormat::Keys || args.keys_legacy;
if keys_mode {
for key in result.affected_keys() {
println!("{}", key);
}
if args.dry_run {
return;
}
}
if !args.quiet && !keys_mode {
if args.dry_run {
println!(
"Would inline [{}]({}) into '{}'",
ref_text, inline_key, source_key
);
if !should_keep_target {
println!("Would delete '{}'", inline_key);
if !result.updates.is_empty() {
println!(
"Would update {} additional document(s)",
result.updates.len() - 1
);
}
}
return;
}
println!(
"Inlining [{}]({}) into '{}'",
ref_text, inline_key, source_key
);
}
if !args.dry_run {
apply_changes(&result, &config);
if !args.quiet && !keys_mode {
println!("Done");
}
}
}
#[tracing::instrument(level = "debug")]
fn update_command(args: Update) {
let body_mode = args.content.is_some();
let mutation_mode = !args.set.is_empty() || !args.unset.is_empty();
if body_mode && mutation_mode {
eprintln!("Error: --content cannot be combined with --set or --unset");
std::process::exit(1);
}
if !body_mode && !mutation_mode {
eprintln!("Error: provide either --content (body overwrite) or --set/--unset (frontmatter mutation)");
std::process::exit(1);
}
if body_mode {
update_body(args);
} else {
update_frontmatter(args);
}
}
fn split_raw_frontmatter(content: &str) -> (Option<&str>, &str) {
if !content.starts_with("---\n") && !content.starts_with("---\r\n") {
return (None, content);
}
let after_open = if content.starts_with("---\r\n") { 5 } else { 4 };
let rest = &content[after_open..];
if let Some(close_pos) = rest.find("\n---\n") {
let end = after_open + close_pos + "\n---\n".len();
return (Some(&content[..end]), &content[end..]);
}
if let Some(close_pos) = rest.find("\r\n---\r\n") {
let end = after_open + close_pos + "\r\n---\r\n".len();
return (Some(&content[..end]), &content[end..]);
}
if rest.ends_with("\n---\n") || rest.ends_with("\n---") {
if let Some(close_pos) = rest.rfind("\n---") {
let end = after_open + close_pos + "\n---".len();
let trailing = &content[end..];
return (Some(&content[..end + trailing.len()]), "");
}
}
(None, content)
}
fn update_body(args: Update) {
let config = get_configuration();
let graph = load_graph(&config);
let key_str = args.key.clone().unwrap_or_else(|| {
eprintln!("Error: -k/--key is required for body-overwrite mode");
std::process::exit(1);
});
let key = Key::name(&key_str);
if (&graph).get_node_id(&key).is_none() {
eprintln!("Error: Document '{}' not found", key_str);
std::process::exit(1);
}
let raw = args.content.expect("body mode implies content present");
let content = if raw == "-" {
let stdin_content = read_stdin_if_available();
if stdin_content.is_empty() {
eprintln!("Error: '--content -' requires content piped via stdin");
std::process::exit(1);
}
stdin_content
} else {
raw
};
if args.dry_run {
if !args.quiet {
println!("Would update '{}' ({} bytes)", key_str, content.len());
}
return;
}
let library_path = get_library_path(&config);
let file_path = library_path.join(format!("{}.{}", key, config.format.extension()));
if let Some(parent) = file_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let existing = std::fs::read_to_string(&file_path).unwrap_or_default();
let (frontmatter, _) = split_raw_frontmatter(&existing);
let output = match frontmatter {
Some(fm) => format!("{}{}", fm, content),
None => content,
};
std::fs::write(&file_path, &output).expect("Failed to write document file");
if !args.quiet {
println!("Updated '{}'", key_str);
}
}
fn update_frontmatter(args: Update) {
use liwe::query::prelude::find;
use liwe::query::wire::RawUpdate;
use liwe::query::{build_update_doc, execute as run_op, FindOp, Outcome};
use serde_yaml::{Mapping, Value};
let config = get_configuration();
let graph = load_graph(&config);
let mut conjuncts: Vec<Filter> = Vec::new();
let parsed_filter = args.filter.as_ref().map(|expr| {
liwe::query::parse_filter_expression(expr).unwrap_or_else(|e| {
eprintln!("error: invalid --filter expression: {}", e);
std::process::exit(2);
})
});
if let (Some(parsed), Some(_)) = (parsed_filter.as_ref(), args.key.as_ref()) {
if filter_has_top_level_key_predicate(parsed) {
eprintln!(
"error: -k / --key conflicts with a $key predicate at the top level of --filter; \
use --filter '$or: [{{$key: a}}, {{$key: b}}]' for OR-of-keys, or pick one source"
);
std::process::exit(2);
}
}
if let Some(parsed) = parsed_filter {
conjuncts.push(parsed);
}
if let Some(k) = &args.key {
conjuncts.push(Filter::Key(liwe::query::KeyOp::Eq(Key::name(k))));
}
if conjuncts.is_empty() {
eprintln!("Error: --filter or -k/--key required for frontmatter mutation mode");
std::process::exit(1);
}
let filter = if conjuncts.len() == 1 {
conjuncts.into_iter().next().unwrap()
} else {
Filter::And(conjuncts)
};
let mut set_map = Mapping::new();
for assign in &args.set {
let (field, value) = parse_set_assignment(assign).unwrap_or_else(|e| {
eprintln!("error: {}", e);
std::process::exit(2);
});
set_map.insert(Value::String(field), value);
}
let mut unset_map = Mapping::new();
for field in &args.unset {
unset_map.insert(Value::String(field.clone()), Value::String(String::new()));
}
let raw_update = RawUpdate {
set: if set_map.is_empty() {
None
} else {
Some(set_map)
},
unset: if unset_map.is_empty() {
None
} else {
Some(unset_map)
},
};
let update_doc = build_update_doc(raw_update).unwrap_or_else(|e| {
eprintln!("error: invalid update: {}", e);
std::process::exit(2);
});
let find_op = FindOp::new().filter(filter);
let outcome = run_op(&find(find_op), &graph);
let keys: Vec<Key> = match outcome {
Outcome::Find { matches } => matches.into_iter().map(|m| m.key).collect(),
_ => unreachable!(),
};
if args.dry_run {
if !args.quiet {
println!("Would update {} document(s)", keys.len());
for key in &keys {
println!(" {}", key);
}
}
return;
}
let library_path = get_library_path(&config);
let mut count = 0;
for key in &keys {
let file_path = library_path.join(format!("{}.{}", key, config.format.extension()));
let raw_content = match std::fs::read_to_string(&file_path) {
Ok(c) => c,
Err(_) => continue,
};
let (_, body) = split_raw_frontmatter(&raw_content);
let mut mapping = graph.frontmatter(key).cloned().unwrap_or_default();
liwe::query::update::apply(&update_doc, &mut mapping);
liwe::query::frontmatter::strip_reserved(&mut mapping);
let yaml = if mapping.is_empty() {
String::new()
} else {
let serialized = serde_yaml::to_string(&mapping).unwrap_or_default();
format!("---\n{}---\n", serialized)
};
let output = format!("{}{}", yaml, body);
std::fs::write(&file_path, &output).expect("Failed to write document file");
count += 1;
}
if !args.quiet {
println!("Updated {} document(s)", count);
}
}
fn filter_has_top_level_key_predicate(filter: &Filter) -> bool {
match filter {
Filter::Key(_) => true,
Filter::And(children) => children.iter().any(filter_has_top_level_key_predicate),
_ => false,
}
}
fn parse_set_assignment(s: &str) -> Result<(String, serde_yaml::Value), String> {
let (field, value) = s
.split_once('=')
.ok_or_else(|| format!("invalid --set assignment '{}': expected FIELD=VALUE", s))?;
if field.is_empty() {
return Err(format!("invalid --set assignment '{}': empty field", s));
}
let yaml_value: serde_yaml::Value = serde_yaml::from_str(value)
.map_err(|e| format!("invalid --set value for '{}': {}", field, e))?;
Ok((field.to_string(), yaml_value))
}
#[tracing::instrument(level = "debug")]
fn attach_command(args: Attach) {
let config = get_configuration();
if args.list {
for (name, action) in &config.actions {
if let ActionDefinition::Attach(a) = action {
let target = render_key_template(&a.key_template);
println!("{}\t{}\t{}", name, a.title, target);
}
}
return;
}
if args.to.is_empty() {
eprintln!("Error: --to <ACTION> is required when not in --list mode (repeatable)");
std::process::exit(1);
}
let source_key_str = args.key.clone().unwrap_or_else(|| {
eprintln!("Error: --key is required when not in --list mode");
std::process::exit(1)
});
let source_key = Key::name(&source_key_str);
let graph = load_graph(&config);
if (&graph).get_node_id(&source_key).is_none() {
eprintln!("Error: Source document '{}' not found", source_key_str);
std::process::exit(1);
}
let reference_text = (&graph)
.get_key_title(&source_key)
.unwrap_or_else(|| source_key_str.clone());
let library_path = get_library_path(&config);
let format_options = graph.format_options().clone();
for action_name in &args.to {
let attach = match config.actions.get(action_name) {
Some(ActionDefinition::Attach(a)) => a.clone(),
Some(_) => {
eprintln!("Error: Action '{}' is not an attach action", action_name);
std::process::exit(1);
}
None => {
eprintln!("Error: Action '{}' not found", action_name);
std::process::exit(1);
}
};
let target_key_str = render_key_template(&attach.key_template);
let target_key = Key::name(&target_key_str);
if (&graph).get_node_id(&target_key).is_some() {
let tree = (&graph).collect(&target_key);
if tree.get_all_inclusion_edge_keys().contains(&source_key) {
continue;
}
}
let reference = ModelTree {
id: None,
node: Node::Reference(Reference {
key: source_key.clone(),
text: reference_text.clone(),
reference_type: ReferenceType::Regular,
url: String::new(),
display_url: None,
}),
children: vec![],
};
let new_content = if (&graph).get_node_id(&target_key).is_some() {
let tree = (&graph).collect(&target_key);
tree.attach(reference)
.iter()
.to_text(&target_key.parent(), &format_options)
} else {
let rendered_reference = reference
.iter()
.to_text(&target_key.parent(), &format_options);
render_document_template(&attach.document_template, &rendered_reference, &config)
};
if args.dry_run {
if !args.quiet {
println!("Would attach '{}' to '{}'", source_key_str, target_key);
}
continue;
}
let target_path =
library_path.join(format!("{}.{}", target_key, config.format.extension()));
if let Some(parent) = target_path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&target_path, new_content).expect("Failed to write target file");
if !args.quiet {
println!(
"Attached '{}' to '{}' as [{}]",
source_key_str, target_key, reference_text
);
}
}
}
fn render_key_template(template: &str) -> String {
use chrono::Local;
use minijinja::{context, Environment};
let now = Local::now();
let formatted = now.format("%Y-%m-%d").to_string();
Environment::new()
.template_from_str(template)
.expect("valid key template")
.render(context! {
today => &formatted,
now => &formatted,
})
.expect("key template to render")
}
fn render_document_template(template: &str, content: &str, config: &Configuration) -> String {
use chrono::Local;
use minijinja::{context, Environment};
let now = Local::now();
let date_format = config
.markdown
.date_format
.as_deref()
.unwrap_or("%b %d, %Y");
let formatted = now.format(date_format).to_string();
Environment::new()
.template_from_str(template)
.expect("valid document template")
.render(context! {
today => &formatted,
now => &formatted,
content => content,
})
.expect("document template to render")
}