use std::io::Read;
use serde_json::json;
use tftio_lib::{
AgentCapability, AgentSurfaceSpec, CommandSelector, FlagSelector, JsonOutput, LicenseType,
ToolSpec, error::print_error, map_standard_command, render_response, run_cli_no_doctor_from,
workspace_tool,
};
use clap::{Parser, Subcommand};
use std::path::PathBuf;
pub use tftio_lib::MetaCommand;
use crate::markdown;
use crate::org_meta;
use crate::parser;
use crate::prompt;
use crate::storage;
use tftio_org::ast::{Block, Document, Tag, Title};
const SEARCH_COMMAND: CommandSelector = CommandSelector::new(&["search"]);
const GET_COMMAND: CommandSelector = CommandSelector::new(&["get"]);
const CREATE_COMMAND: CommandSelector = CommandSelector::new(&["create"]);
const UPDATE_COMMAND: CommandSelector = CommandSelector::new(&["update"]);
const DELETE_COMMAND: CommandSelector = CommandSelector::new(&["delete"]);
const RECENT_COMMAND: CommandSelector = CommandSelector::new(&["recent"]);
const LIST_BY_TAG_COMMAND: CommandSelector = CommandSelector::new(&["list-by-tag"]);
const LINKS_COMMAND: CommandSelector = CommandSelector::new(&["links"]);
const ORPHANS_COMMAND: CommandSelector = CommandSelector::new(&["orphans"]);
const HUBS_COMMAND: CommandSelector = CommandSelector::new(&["hubs"]);
const BROKEN_COMMAND: CommandSelector = CommandSelector::new(&["broken"]);
const PROMPT_RENDER_COMMAND: CommandSelector = CommandSelector::new(&["prompt", "render"]);
const PROMPT_LIST_COMMAND: CommandSelector = CommandSelector::new(&["prompt", "list"]);
const PROMPT_SHOW_COMMAND: CommandSelector = CommandSelector::new(&["prompt", "show"]);
const SEARCH_JSON_FLAG: FlagSelector = FlagSelector::new(&["search"], "json");
const GET_JSON_FLAG: FlagSelector = FlagSelector::new(&["get"], "json");
const CREATE_ID_FLAG: FlagSelector = FlagSelector::new(&["create"], "id");
const CREATE_TAG_FLAG: FlagSelector = FlagSelector::new(&["create"], "tag");
const CREATE_MARKDOWN_FLAG: FlagSelector = FlagSelector::new(&["create"], "markdown");
const CREATE_JSON_FLAG: FlagSelector = FlagSelector::new(&["create"], "json");
const UPDATE_TAG_FLAG: FlagSelector = FlagSelector::new(&["update"], "tag");
const UPDATE_MARKDOWN_FLAG: FlagSelector = FlagSelector::new(&["update"], "markdown");
const UPDATE_JSON_FLAG: FlagSelector = FlagSelector::new(&["update"], "json");
const RECENT_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["recent"], "limit");
const RECENT_JSON_FLAG: FlagSelector = FlagSelector::new(&["recent"], "json");
const LIST_BY_TAG_JSON_FLAG: FlagSelector = FlagSelector::new(&["list-by-tag"], "json");
const LINKS_JSON_FLAG: FlagSelector = FlagSelector::new(&["links"], "json");
const ORPHANS_JSON_FLAG: FlagSelector = FlagSelector::new(&["orphans"], "json");
const HUBS_LIMIT_FLAG: FlagSelector = FlagSelector::new(&["hubs"], "limit");
const HUBS_JSON_FLAG: FlagSelector = FlagSelector::new(&["hubs"], "json");
const BROKEN_JSON_FLAG: FlagSelector = FlagSelector::new(&["broken"], "json");
const PROMPT_LIST_JSON_FLAG: FlagSelector = FlagSelector::new(&["prompt", "list"], "json");
const PROMPT_SHOW_JSON_FLAG: FlagSelector = FlagSelector::new(&["prompt", "show"], "json");
const GLOBAL_DB_FLAG: FlagSelector = FlagSelector::new(&[], "db");
const SEARCH_CAPABILITY: AgentCapability = AgentCapability::new(
"search",
"Full-text search across knowledge base nodes using FTS5 query syntax",
&[SEARCH_COMMAND],
&[SEARCH_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use(
"the user wants to find knowledge-base nodes by keyword, phrase, or FTS5 query expression",
)
.with_when_not_to_use(
"the user already knows the exact node id (use get instead), \
or wants nodes by tag rather than content (use list-by-tag instead)",
)
.with_output("a JSON array of {id, title} summaries ranked by FTS5 relevance");
const GET_CAPABILITY: AgentCapability = AgentCapability::new(
"get",
"Retrieve a single knowledge-base node by id, returning the full document, \
derived title, tag list, and storage timestamps",
&[GET_COMMAND],
&[GET_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user references a specific node id and wants to see its full contents")
.with_when_not_to_use(
"the user wants to browse or discover nodes (use search, recent, or list-by-tag instead)",
)
.with_output("a JSON node view: id, title, tags, document AST, createdAt, updatedAt");
const CREATE_CAPABILITY: AgentCapability = AgentCapability::new(
"create",
"Create a new knowledge-base node. The document body is read from stdin as org-mode text \
or as a JSON kb AST Document; with --markdown it is read as GitHub-flavored markdown and \
converted via pandoc. An id may be supplied via --id; otherwise a UUIDv4 is minted. \
Tags supplied via --tag are merged with any tags parsed from the document body.",
&[CREATE_COMMAND],
&[
CREATE_ID_FLAG,
CREATE_TAG_FLAG,
CREATE_MARKDOWN_FLAG,
CREATE_JSON_FLAG,
GLOBAL_DB_FLAG,
],
)
.with_when_to_use(
"the user wants to persist a new piece of knowledge — a note, a transcript, a design doc, \
or any org-mode text — into the knowledge base",
)
.with_when_not_to_use(
"the node already exists and should be modified (use update instead), \
or the id supplied via --id is already taken (the command will fail with a conflict error)",
)
.with_output(
"a JSON node view of the created node, including the server-assigned or caller-supplied id",
);
const UPDATE_CAPABILITY: AgentCapability = AgentCapability::new(
"update",
"Replace an existing node's document. The new body is read from stdin as org-mode text \
or as a JSON kb AST Document; with --markdown it is read as GitHub-flavored markdown and \
converted via pandoc. Tags supplied via --tag are merged with any tags parsed \
from the new body.",
&[UPDATE_COMMAND],
&[
UPDATE_TAG_FLAG,
UPDATE_MARKDOWN_FLAG,
UPDATE_JSON_FLAG,
GLOBAL_DB_FLAG,
],
)
.with_when_to_use("the user wants to replace the contents of an existing knowledge-base node")
.with_when_not_to_use(
"the node does not yet exist (use create instead), \
or the id is unknown (the command will fail with 'no node with id')",
)
.with_output("a JSON node view of the updated node with the refreshed updatedAt timestamp");
const DELETE_CAPABILITY: AgentCapability = AgentCapability::new(
"delete",
"Permanently delete a knowledge-base node by id. This cascades to node tags and links. \
The prior document is preserved in the audit log.",
&[DELETE_COMMAND],
&[GLOBAL_DB_FLAG],
)
.with_when_to_use("the user explicitly asks to delete a specific node from the knowledge base")
.with_when_not_to_use(
"the user is unsure about deletion, or the node id is unknown \
(the command will report 'no node with id' rather than silently succeeding)",
)
.with_output("plain-text confirmation 'deleted <id>' on success");
const RECENT_CAPABILITY: AgentCapability = AgentCapability::new(
"recent",
"List the most recently updated knowledge-base nodes, newest first. \
Defaults to 50 nodes; use --limit to adjust.",
&[RECENT_COMMAND],
&[RECENT_LIMIT_FLAG, RECENT_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to see what was recently added or changed in the knowledge base")
.with_when_not_to_use(
"the user wants nodes matching a specific topic (use search) or tag (use list-by-tag)",
)
.with_output("a JSON array of {id, title} summaries ordered by updatedAt descending");
const LIST_BY_TAG_CAPABILITY: AgentCapability = AgentCapability::new(
"list-by-tag",
"List knowledge-base node summaries for all nodes carrying a given tag. \
The tag is supplied without surrounding colons (e.g. 'rust' not ':rust:').",
&[LIST_BY_TAG_COMMAND],
&[LIST_BY_TAG_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to enumerate all nodes under a specific tag")
.with_when_not_to_use(
"the user wants full-text search across node bodies (use search instead), \
or wants to browse by recency (use recent instead)",
)
.with_output("a JSON array of {id, title} summaries for nodes carrying the tag");
const LINKS_CAPABILITY: AgentCapability = AgentCapability::new(
"links",
"Show the forward (outgoing) and back (incoming) links for a node under the unified \
link graph. Both `[[id:UUID]]` id-links and `[[name]]` bracket name-links coexist \
in a single physical table discriminated by a `link_type` column ('id' | 'name'). \
Each row reports `{source_id, link_type, target_id, target_slug}`. For id-link rows \
`target_id` is always non-null and `target_slug` is null; for name-link rows \
`target_slug` is always non-null and `target_id` is null when the bracket reference \
is broken (no node carries that `#+name:` slug yet).",
&[LINKS_COMMAND],
&[LINKS_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use(
"the user wants to see what a node references (by either id or name) and what references it back",
)
.with_when_not_to_use(
"the user wants global metrics like hubs / orphans / broken (use those verbs instead)",
)
.with_output(
"a JSON object {outgoing: [...], incoming: [...]} of unified link rows including \
link_type for the node",
);
const ORPHANS_CAPABILITY: AgentCapability = AgentCapability::new(
"orphans",
"List orphan nodes — nodes that nothing else points at under either link_type. \
A node is an orphan when no `links` row of any link_type ('id' or 'name') has a \
resolved `target_id` equal to its id.",
&[ORPHANS_COMMAND],
&[ORPHANS_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to find isolated nodes in the knowledge base graph")
.with_when_not_to_use(
"the user wants nodes by tag (use list-by-tag), by recency (use recent), \
or by full-text content (use search)",
)
.with_output(
"a JSON array of {id, title} summaries for nodes with zero incoming links of any link_type",
);
const HUBS_CAPABILITY: AgentCapability = AgentCapability::new(
"hubs",
"List the most-linked nodes ranked by total in-degree across both link_types \
(id-links plus resolved name-links) in the unified graph. Defaults to top 20.",
&[HUBS_COMMAND],
&[HUBS_LIMIT_FLAG, HUBS_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to see which nodes act as graph hubs in the knowledge base")
.with_when_not_to_use(
"the user wants the full link table rather than ranked summaries (use links instead)",
)
.with_output("a JSON array of {id, title, in_degree} entries ordered by in-degree descending");
const BROKEN_CAPABILITY: AgentCapability = AgentCapability::new(
"broken",
"List broken bracket references in the unified link graph: rows with link_type='name' \
whose `target_id` is NULL because no node carries the referenced `#+name:` slug. \
Id-links cannot be broken by construction — the schema-level CHECK enforces a \
non-null target_id for link_type='id'.",
&[BROKEN_COMMAND],
&[BROKEN_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to find dangling bracket references in the knowledge base")
.with_when_not_to_use("the user wants to inspect a single node's links (use links instead)")
.with_output(
"a JSON array of {source_id, link_type, target_id, target_slug} rows for unresolved \
bracket references",
);
const PROMPT_RENDER_CAPABILITY: AgentCapability = AgentCapability::new(
"prompt-render",
"Render a MiniJinja template against the local kb corpus and write the rendered text \
to stdout. Templates have access to a documented query surface — `recent`, `orphans`, \
`hubs`, `tag_frequency` as eagerly bound corpus snapshots, and `by_tag`, `search`, \
`all_nodes`, `get`, `links`, `link_distance` as callables. Built-in templates ship \
embedded with the crate and are overridable by files at \
`$XDG_CONFIG_HOME/kb/prompts/<name>.j2`. kb assembles; the user executes — kb does not \
pipe the rendered text into any LLM, that is the caller's job.",
&[PROMPT_RENDER_COMMAND],
&[GLOBAL_DB_FLAG],
)
.with_when_to_use(
"the user wants to assemble an LLM prompt from kb contents using a named template",
)
.with_when_not_to_use(
"the user wants to inspect available templates (use prompt list) or view a template's \
source (use prompt show)",
)
.with_output("the rendered template text on stdout — no envelope; pipe it directly to an LLM");
const PROMPT_LIST_CAPABILITY: AgentCapability = AgentCapability::new(
"prompt-list",
"List every template available to `kb prompt`, merging built-in templates embedded with \
the crate with user overrides found under `$XDG_CONFIG_HOME/kb/prompts/<name>.j2`. \
User overrides win on name collision.",
&[PROMPT_LIST_COMMAND],
&[PROMPT_LIST_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to enumerate the prompt templates kb can render")
.with_when_not_to_use("the user already knows the template name and wants to render or inspect it")
.with_output("a JSON array of {name, source, path} entries, sorted by name");
const PROMPT_SHOW_CAPABILITY: AgentCapability = AgentCapability::new(
"prompt-show",
"Print the raw source of the named template — whichever wins resolution between the \
user override and the built-in. Useful for inspecting what a template will do before \
rendering it.",
&[PROMPT_SHOW_COMMAND],
&[PROMPT_SHOW_JSON_FLAG, GLOBAL_DB_FLAG],
)
.with_when_to_use("the user wants to read a template's body without rendering it")
.with_when_not_to_use("the user wants the rendered output (use prompt render)")
.with_output("the template body as plain text; with --json, a {name, source, path, body} envelope");
const AGENT_SURFACE: AgentSurfaceSpec = AgentSurfaceSpec::new(&[
SEARCH_CAPABILITY,
GET_CAPABILITY,
CREATE_CAPABILITY,
UPDATE_CAPABILITY,
DELETE_CAPABILITY,
RECENT_CAPABILITY,
LIST_BY_TAG_CAPABILITY,
LINKS_CAPABILITY,
ORPHANS_CAPABILITY,
HUBS_CAPABILITY,
BROKEN_CAPABILITY,
PROMPT_RENDER_CAPABILITY,
PROMPT_LIST_CAPABILITY,
PROMPT_SHOW_CAPABILITY,
]);
const TOOL_SPEC: ToolSpec = workspace_tool(
"kb",
"kb",
env!("CARGO_PKG_VERSION"),
LicenseType::MIT,
true,
false,
)
.with_agent_surface(&AGENT_SURFACE);
#[must_use]
pub fn main_exit_code() -> i32 {
let env = process_env();
run_cli_no_doctor_from::<Cli, _, _, _>(
&TOOL_SPEC,
&env,
std::env::args_os(),
metadata_command,
|cli| Ok(run(cli)),
)
}
#[allow(
clippy::disallowed_methods,
reason = "agent token / HOME read once at the process edge (REPO_INVARIANTS.md #5)"
)]
fn process_env() -> tftio_lib::ProcessEnv {
tftio_lib::ProcessEnv {
agent: tftio_lib::AgentModeContext::from_tokens(
std::env::var(tftio_lib::AGENT_TOKEN_ENV).ok(),
std::env::var(tftio_lib::AGENT_TOKEN_EXPECTED_ENV).ok(),
),
home: std::env::var_os("HOME").map(std::path::PathBuf::from),
}
}
#[must_use]
pub fn metadata_command(cli: &Cli) -> Option<tftio_lib::StandardCommand> {
match &cli.command {
Command::Meta { command } => Some(map_standard_command(command, JsonOutput::Text)),
_ => None,
}
}
#[must_use]
pub fn run(cli: Cli) -> i32 {
match cli.command {
Command::Meta { .. } => unreachable!("meta commands are routed before run"),
Command::Search { query, json } => run_search(&cli.db, &query, JsonOutput::from_flag(json)),
Command::Get { id, json } => run_get(&cli.db, &id, JsonOutput::from_flag(json)),
Command::Create {
id,
tags,
markdown,
json,
} => run_create(
&cli.db,
id.as_deref(),
&tags,
markdown,
JsonOutput::from_flag(json),
),
Command::Update {
id,
tags,
markdown,
json,
} => run_update(&cli.db, &id, &tags, markdown, JsonOutput::from_flag(json)),
Command::Delete { id } => run_delete(&cli.db, &id),
Command::Recent { limit, json } => run_recent(&cli.db, limit, JsonOutput::from_flag(json)),
Command::ListByTag { tag, json } => {
run_list_by_tag(&cli.db, &tag, JsonOutput::from_flag(json))
}
Command::Links { id, json } => run_links(&cli.db, &id, JsonOutput::from_flag(json)),
Command::Orphans { json } => run_orphans(&cli.db, JsonOutput::from_flag(json)),
Command::Hubs { limit, json } => run_hubs(&cli.db, limit, JsonOutput::from_flag(json)),
Command::Broken { json } => run_broken(&cli.db, JsonOutput::from_flag(json)),
Command::Prompt { command } => run_prompt(&cli.db, command),
}
}
fn run_prompt(db_path: &std::path::Path, command: PromptCommand) -> i32 {
match command {
PromptCommand::Render { name } => run_prompt_render(db_path, &name),
PromptCommand::List { json } => run_prompt_list(JsonOutput::from_flag(json)),
PromptCommand::Show { name, json } => run_prompt_show(&name, JsonOutput::from_flag(json)),
}
}
fn run_prompt_render(db_path: &std::path::Path, name: &str) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => {
eprintln!("kb: {e:#}");
return 1;
}
};
match prompt::render_prompt(&conn, name) {
Ok(text) => {
if text.ends_with('\n') {
print!("{text}");
} else {
println!("{text}");
}
0
}
Err(e) => {
eprintln!("kb: prompt render failed: {e}");
1
}
}
}
fn run_prompt_list(json: JsonOutput) -> i32 {
let summaries = prompt::list_templates();
let payload = serde_json::Value::Array(
summaries
.iter()
.map(|t| {
json!({
"name": t.name,
"source": t.source,
"path": t.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
})
})
.collect(),
);
let text = summaries
.iter()
.map(|t| {
t.path.as_ref().map_or_else(
|| format!("{}\t{}", t.name, t.source),
|p| format!("{}\t{}\t{}", t.name, t.source, p.display()),
)
})
.collect::<Vec<_>>()
.join("\n");
println!("{}", render_response("prompt-list", json, payload, text));
0
}
fn run_prompt_show(name: &str, json: JsonOutput) -> i32 {
let resolved = match prompt::resolve_template_source(name) {
Ok(r) => r,
Err(e) => return print_error("prompt-show", json, &e.to_string()),
};
let payload = json!({
"name": resolved.name,
"source": resolved.source,
"path": resolved.path.as_ref().map(|p| p.to_string_lossy().into_owned()),
"body": resolved.body,
});
println!(
"{}",
render_response("prompt-show", json, payload, resolved.body)
);
0
}
#[derive(Debug, Parser)]
#[command(name = "kb")]
pub struct Cli {
#[arg(long, env = "KB_DB_PATH", default_value_os_t = crate::storage::default_db_path())]
pub db: PathBuf,
#[command(subcommand)]
pub command: Command,
}
#[derive(Debug, Subcommand)]
pub enum Command {
Meta {
#[command(subcommand)]
command: MetaCommand,
},
Search {
query: String,
#[arg(long)]
json: bool,
},
Get {
id: String,
#[arg(long)]
json: bool,
},
Create {
#[arg(long)]
id: Option<String>,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long)]
markdown: bool,
#[arg(long)]
json: bool,
},
Update {
id: String,
#[arg(long = "tag")]
tags: Vec<String>,
#[arg(long)]
markdown: bool,
#[arg(long)]
json: bool,
},
Delete {
id: String,
},
Recent {
#[arg(long, default_value = "50")]
limit: usize,
#[arg(long)]
json: bool,
},
ListByTag {
tag: String,
#[arg(long)]
json: bool,
},
Links {
id: String,
#[arg(long)]
json: bool,
},
Orphans {
#[arg(long)]
json: bool,
},
Hubs {
#[arg(long, default_value = "20")]
limit: usize,
#[arg(long)]
json: bool,
},
Broken {
#[arg(long)]
json: bool,
},
Prompt {
#[command(subcommand)]
command: PromptCommand,
},
}
#[derive(Debug, Subcommand)]
pub enum PromptCommand {
Render {
name: String,
},
List {
#[arg(long)]
json: bool,
},
Show {
name: String,
#[arg(long)]
json: bool,
},
}
fn open_or_die(db_path: &std::path::Path) -> Result<rusqlite::Connection, crate::error::KbError> {
let conn = storage::open_db(&db_path.to_string_lossy())?;
storage::init_db(&conn)?;
Ok(conn)
}
fn read_stdin() -> Result<String, i32> {
let mut buf = String::new();
match std::io::stdin().read_to_string(&mut buf) {
Ok(_) => Ok(buf),
Err(e) => {
eprintln!("kb: failed to read stdin: {e}");
Err(1)
}
}
}
#[derive(Debug, thiserror::Error)]
enum BodyParseError {
#[error("invalid JSON Document: {0}")]
Json(#[from] serde_json::Error),
#[error("{0}")]
Org(#[from] parser::ParseError),
#[error("{0}")]
Markdown(#[from] markdown::MarkdownError),
}
fn parse_input_body(raw: &str, markdown: bool) -> Result<Document, BodyParseError> {
if markdown {
Ok(markdown::markdown_to_document(raw)?)
} else {
parse_body(raw)
}
}
fn parse_body(raw: &str) -> Result<Document, BodyParseError> {
if raw.trim().starts_with('{') {
Ok(serde_json::from_str::<Document>(raw)?)
} else {
Ok(parser::parse_document(raw)?)
}
}
fn merge_cli_tags(doc: &mut Document, cli_tags: &[String]) {
if cli_tags.is_empty() {
return;
}
let new_tags: Vec<Tag> = cli_tags.iter().map(|t| Tag(t.clone())).collect();
for block in &mut doc.blocks {
if let Block::Heading { tags, .. } = block {
tags.extend(new_tags);
return;
}
}
doc.blocks.insert(
0,
Block::Heading {
level: 1,
title: Title(String::new()),
tags: new_tags,
children: vec![],
},
);
}
fn node_view_json(id: &str, nf: &storage::NodeFullData) -> serde_json::Value {
json!({
"id": id,
"title": nf.title,
"tags": nf.tags.iter().map(|t| &t.0).collect::<Vec<_>>(),
"document": nf.document,
"createdAt": nf.created_at,
"updatedAt": nf.updated_at,
})
}
fn node_view_text(id: &str, title: &str, tags: &[Tag]) -> String {
let tag_str: Vec<String> = tags.iter().map(|t| format!(":{}", t.0)).collect();
format!("{id} {title} {}\n", tag_str.join(""))
}
fn summaries_json(pairs: &[(String, String)]) -> serde_json::Value {
let arr: Vec<serde_json::Value> = pairs
.iter()
.map(|(id, title)| json!({"id": id, "title": title}))
.collect();
serde_json::Value::Array(arr)
}
fn summaries_text(pairs: &[(String, String)]) -> String {
pairs
.iter()
.map(|(id, title)| format!("{id} {title}"))
.collect::<Vec<_>>()
.join("\n")
}
fn run_search(db_path: &std::path::Path, query: &str, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("search", json, &e.to_string()),
};
let ids = match storage::search_fts(&conn, query) {
Ok(ids) => ids,
Err(e) => return print_error("search", json, &e.to_string()),
};
let pairs = match storage::fetch_titles(&conn, &ids) {
Ok(p) => p,
Err(e) => return print_error("search", json, &e.to_string()),
};
println!(
"{}",
render_response(
"search",
json,
summaries_json(&pairs),
summaries_text(&pairs)
)
);
0
}
fn run_get(db_path: &std::path::Path, id: &str, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("get", json, &e.to_string()),
};
let nf = match storage::get_node_full(&conn, id) {
Ok(Some(nf)) => nf,
Ok(None) => return print_error("get", json, &format!("no node with id: {id}")),
Err(e) => return print_error("get", json, &e.to_string()),
};
let payload = node_view_json(id, &nf);
let text = org_meta::render_with_metadata(id, &nf.created_at, &nf.updated_at, &nf.document);
println!("{}", render_response("get", json, payload, text));
0
}
fn run_create(
db_path: &std::path::Path,
id: Option<&str>,
tags: &[String],
markdown: bool,
json: JsonOutput,
) -> i32 {
let raw = match read_stdin() {
Ok(r) => r,
Err(code) => return code,
};
let parsed = parse_input_body(&raw, markdown);
let mut doc = match parsed {
Ok(d) => d,
Err(e) => return print_error("create", json, &e.to_string()),
};
let _ = org_meta::hydrate(&mut doc);
merge_cli_tags(&mut doc, tags);
let nid = id.map_or_else(|| uuid::Uuid::new_v4().to_string(), String::from);
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("create", json, &e.to_string()),
};
if let Err(e) = storage::insert_node(&conn, &nid, &doc) {
return print_error("create", json, &e.to_string());
}
let nf = match storage::get_node_full(&conn, &nid) {
Ok(Some(nf)) => nf,
Ok(None) => return print_error("create", json, "node created but disappeared"),
Err(e) => return print_error("create", json, &e.to_string()),
};
let payload = node_view_json(&nid, &nf);
let text = node_view_text(&nid, &nf.title, &nf.tags);
println!("{}", render_response("create", json, payload, text));
0
}
fn run_update(
db_path: &std::path::Path,
id: &str,
tags: &[String],
markdown: bool,
json: JsonOutput,
) -> i32 {
let raw = match read_stdin() {
Ok(r) => r,
Err(code) => return code,
};
let parsed = parse_input_body(&raw, markdown);
let mut doc = match parsed {
Ok(d) => d,
Err(e) => return print_error("update", json, &e.to_string()),
};
if let Some(drawer_id) = org_meta::hydrate(&mut doc)
&& drawer_id != id
{
return print_error(
"update",
json,
&format!("document :ID: {drawer_id} does not match target node id {id}"),
);
}
merge_cli_tags(&mut doc, tags);
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("update", json, &e.to_string()),
};
match storage::update_node(&conn, id, &doc) {
Ok(true) => {}
Ok(false) => return print_error("update", json, &format!("no node with id: {id}")),
Err(e) => return print_error("update", json, &e.to_string()),
}
let nf = match storage::get_node_full(&conn, id) {
Ok(Some(nf)) => nf,
Ok(None) => return print_error("update", json, "node updated but disappeared"),
Err(e) => return print_error("update", json, &e.to_string()),
};
let payload = node_view_json(id, &nf);
let text = node_view_text(id, &nf.title, &nf.tags);
println!("{}", render_response("update", json, payload, text));
0
}
fn run_delete(db_path: &std::path::Path, id: &str) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => {
eprintln!("kb: {e:#}");
return 1;
}
};
match storage::delete_node(&conn, id) {
Ok(true) => {
println!("deleted {id}");
0
}
Ok(false) => {
eprintln!("kb: no node with id: {id}");
1
}
Err(e) => {
eprintln!("kb: delete failed: {e}");
1
}
}
}
fn run_recent(db_path: &std::path::Path, limit: usize, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("recent", json, &e.to_string()),
};
let rows = match storage::list_recent(&conn, limit) {
Ok(r) => r,
Err(e) => return print_error("recent", json, &e.to_string()),
};
let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
let pairs = match storage::fetch_titles(&conn, &ids) {
Ok(p) => p,
Err(e) => return print_error("recent", json, &e.to_string()),
};
println!(
"{}",
render_response(
"recent",
json,
summaries_json(&pairs),
summaries_text(&pairs)
)
);
0
}
fn link_row_json(row: &storage::LinkRow) -> serde_json::Value {
json!({
"source_id": row.source_id,
"link_type": row.link_type,
"target_id": row.target_id,
"target_slug": row.target_slug,
})
}
fn link_row_text(row: &storage::LinkRow) -> String {
match (row.link_type.as_str(), &row.target_id, &row.target_slug) {
("id", Some(tgt), _) => format!("{} [id] -> {}", row.source_id, tgt),
("name", Some(tgt), Some(slug)) => {
format!("{} [name] [[{slug}]] -> {tgt}", row.source_id)
}
("name", None, Some(slug)) => {
format!("{} [name] [[{slug}]] -> (broken)", row.source_id)
}
_ => format!(
"{} [{}] target_id={:?} target_slug={:?}",
row.source_id, row.link_type, row.target_id, row.target_slug
),
}
}
fn run_links(db_path: &std::path::Path, id: &str, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("links", json, &e.to_string()),
};
let nb = match storage::get_links(&conn, id) {
Ok(n) => n,
Err(e) => return print_error("links", json, &e.to_string()),
};
let payload = json!({
"outgoing": nb.outgoing.iter().map(link_row_json).collect::<Vec<_>>(),
"incoming": nb.incoming.iter().map(link_row_json).collect::<Vec<_>>(),
});
let mut text_lines = Vec::new();
text_lines.push("outgoing:".to_string());
for row in &nb.outgoing {
text_lines.push(format!(" {}", link_row_text(row)));
}
text_lines.push("incoming:".to_string());
for row in &nb.incoming {
text_lines.push(format!(" {}", link_row_text(row)));
}
println!(
"{}",
render_response("links", json, payload, text_lines.join("\n"))
);
0
}
fn run_orphans(db_path: &std::path::Path, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("orphans", json, &e.to_string()),
};
let rows = match storage::list_orphans(&conn) {
Ok(r) => r,
Err(e) => return print_error("orphans", json, &e.to_string()),
};
let pairs: Vec<(String, String)> = rows
.iter()
.map(|r| (r.id.0.clone(), r.title.0.clone()))
.collect();
println!(
"{}",
render_response(
"orphans",
json,
summaries_json(&pairs),
summaries_text(&pairs)
)
);
0
}
fn run_hubs(db_path: &std::path::Path, limit: usize, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("hubs", json, &e.to_string()),
};
let hubs = match storage::list_hubs(&conn, limit) {
Ok(h) => h,
Err(e) => return print_error("hubs", json, &e.to_string()),
};
let payload = serde_json::Value::Array(
hubs.iter()
.map(|h| json!({"id": h.id, "title": h.title, "in_degree": h.in_degree}))
.collect(),
);
let text = hubs
.iter()
.map(|h| format!("{}\t{}\t{}", h.in_degree, h.id, h.title))
.collect::<Vec<_>>()
.join("\n");
println!("{}", render_response("hubs", json, payload, text));
0
}
fn run_broken(db_path: &std::path::Path, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("broken", json, &e.to_string()),
};
let rows = match storage::list_broken_links(&conn) {
Ok(r) => r,
Err(e) => return print_error("broken", json, &e.to_string()),
};
let payload = serde_json::Value::Array(rows.iter().map(link_row_json).collect::<Vec<_>>());
let text = rows
.iter()
.map(link_row_text)
.collect::<Vec<_>>()
.join("\n");
println!("{}", render_response("broken", json, payload, text));
0
}
fn run_list_by_tag(db_path: &std::path::Path, tag: &str, json: JsonOutput) -> i32 {
let conn = match open_or_die(db_path) {
Ok(c) => c,
Err(e) => return print_error("list-by-tag", json, &e.to_string()),
};
let rows = match storage::list_by_tag(&conn, tag) {
Ok(r) => r,
Err(e) => return print_error("list-by-tag", json, &e.to_string()),
};
let ids: Vec<String> = rows.iter().map(|r| r.id.0.clone()).collect();
let pairs = match storage::fetch_titles(&conn, &ids) {
Ok(p) => p,
Err(e) => return print_error("list-by-tag", json, &e.to_string()),
};
println!(
"{}",
render_response(
"list-by-tag",
json,
summaries_json(&pairs),
summaries_text(&pairs)
)
);
0
}