From 0f559f6d5947cd195b665d59e4d7df77dfd1958a Mon Sep 17 00:00:00 2001 From: strider Date: Mon, 13 Jul 2026 21:48:12 -0400 Subject: [PATCH 1/2] feat(cli): --base-ns and --timestamp flags on knot/episode/retract (#28, #27) Both flags expose library capabilities the CLI layer previously hid behind hardcoded defaults; no library change. --base-ns (#28): `episode` minted IRIs in the fixed aegis namespace, forcing non-aegis deployments to route around the (validation-carrying) episode abstraction via verbatim-Turtle knot. `episode` now takes `--base-ns `, defaulting to DEFAULT_BASE_NS. (Not added to `knot`: it ingests verbatim, so base-ns is a genuine no-op there.) --timestamp (#27): `knot`, `episode`, and `retract` hardcoded now() for valid_from, recording only transaction-time. When ingesting from a system-of-record the meaningful valid-time is the source event time. All three now accept `--timestamp `, defaulting to now. A lightweight shape check rejects obviously-malformed values before they reach valid_from (the CLI carries no chrono dep, so it is not a full parse). Factors the repeated `--flag value` idiom into `flag_value()` and adds `resolve_timestamp()`. Tested end-to-end: a supplied --timestamp drives valid_from (temporal query returns 0 before / 1 after), and --base-ns mints IRIs in the given namespace. Closes #28 Closes #27 Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VsuPDxSUjMYqHvQyPvSAPW --- docs/book/src/reference/cli.md | 10 ++++ src/cli.rs | 94 +++++++++++++++++++++++++++++++--- src/cli_commands.rs | 27 +++++----- 3 files changed, 110 insertions(+), 21 deletions(-) diff --git a/docs/book/src/reference/cli.md b/docs/book/src/reference/cli.md index 2c3d0d5..71cce8a 100644 --- a/docs/book/src/reference/cli.md +++ b/docs/book/src/reference/cli.md @@ -17,11 +17,13 @@ Load RDF facts from a Turtle file. ```bash quipu knot data.ttl --db my.db quipu knot data.ttl --shapes schema.ttl --db my.db # With SHACL validation +quipu knot data.ttl --timestamp 2026-03-15T00:00:00Z --db my.db # Source-true valid-time ``` | Flag | Description | |------|-------------| | `--shapes ` | SHACL shapes file for write-time validation | +| `--timestamp ` | `valid_from` for the facts (default: now). Supply the source event time when ingesting history | Alias: `load` @@ -73,8 +75,15 @@ Ingest a structured episode from a JSON file. ```bash quipu episode deploy.json --db my.db echo '{"name": "test", "nodes": []}' | quipu episode - --db my.db # stdin +quipu episode deploy.json --base-ns "https://quarterdeck.internal/ontology#" --db my.db +quipu episode deploy.json --timestamp 2026-03-15T00:00:00Z --db my.db ``` +| Flag | Description | +|------|-------------| +| `--base-ns ` | Namespace to mint entity IRIs in (default: the built-in aegis namespace). Lets non-aegis deployments use the episode abstraction | +| `--timestamp ` | `valid_from` for the facts (default: now) | + ### `quipu retract ` Retract facts for an entity. @@ -87,6 +96,7 @@ quipu retract "http://example.org/alice" --predicate "http://example.org/email" | Flag | Description | |------|-------------| | `--predicate ` | Only retract facts with this predicate | +| `--timestamp ` | Transaction valid-time for the retraction (default: now) | ### `quipu shapes` diff --git a/src/cli.rs b/src/cli.rs index 167d482..cca01e0 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,19 +9,54 @@ pub fn chrono_now() -> String { quipu::time::now_iso() } +/// Extract the value following a `--flag` in the hand-rolled arg list, matching +/// the existing `--shapes` / `--predicate` idiom. +pub fn flag_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> { + args.windows(2) + .find(|w| w[0] == name) + .map(|w| w[1].as_str()) +} + +/// Resolve a caller-supplied `--timestamp`, falling back to now (quipu #27). +/// +/// Rejects an obviously malformed value early rather than writing a corrupt +/// `valid_from` into the bitemporal store. We keep the check lightweight (the +/// CLI intentionally carries no chrono dependency): an ISO-8601 instant starts +/// with a `YYYY-MM-DD` date. Full parsing is the store's responsibility. +pub fn resolve_timestamp(args: &[String]) -> String { + match flag_value(args, "--timestamp") { + Some(ts) => { + if !looks_like_iso8601(ts) { + eprintln!("error: --timestamp must be ISO-8601 (e.g. 2026-07-13T12:00:00Z), got: {ts}"); + std::process::exit(1); + } + ts.to_string() + } + None => chrono_now(), + } +} + +/// Lightweight ISO-8601 shape check: a leading `YYYY-MM-DD` date. Deliberately +/// not a full parse (the CLI carries no chrono dependency) — it only rejects +/// obviously wrong values before they reach the store's `valid_from`. +fn looks_like_iso8601(ts: &str) -> bool { + ts.len() >= 10 + && ts.as_bytes()[..10] + .iter() + .enumerate() + .all(|(i, b)| if i == 4 || i == 7 { *b == b'-' } else { b.is_ascii_digit() }) +} + pub fn cmd_knot(args: &[String], db_path: &str) { let file_path = match args.get(2) { Some(p) if !p.starts_with("--") => p.as_str(), _ => { - eprintln!("usage: quipu knot [--shapes ] [--db ]"); + eprintln!("usage: quipu knot [--shapes ] [--timestamp ] [--db ]"); std::process::exit(1); } }; - let shapes_path = args - .windows(2) - .find(|w| w[0] == "--shapes") - .map(|w| w[1].as_str()); + let shapes_path = flag_value(args, "--shapes"); let mut store = match quipu::Store::open(db_path) { Ok(s) => s, @@ -79,7 +114,7 @@ pub fn cmd_knot(args: &[String], db_path: &str) { RdfFormat::Turtle }; - let now = chrono_now(); + let now = resolve_timestamp(args); match quipu::ingest_rdf( &mut store, data.as_bytes(), @@ -570,3 +605,50 @@ fn run_query_temporal(store: &quipu::Store, sparql: &str, ctx: &quipu::TemporalC } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn args(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn test_flag_value_found_and_missing() { + let a = args(&["quipu", "knot", "f.ttl", "--shapes", "s.ttl", "--db", "x"]); + assert_eq!(flag_value(&a, "--shapes"), Some("s.ttl")); + assert_eq!(flag_value(&a, "--db"), Some("x")); + assert_eq!(flag_value(&a, "--timestamp"), None); + } + + #[test] + fn test_flag_value_trailing_flag_has_no_value() { + // A flag in the final position has no following value. + let a = args(&["quipu", "retract", "iri", "--predicate"]); + assert_eq!(flag_value(&a, "--predicate"), None); + } + + #[test] + fn test_resolve_timestamp_defaults_to_now() { + // Absent --timestamp falls back to a generated ISO instant. + let a = args(&["quipu", "knot", "f.ttl"]); + assert!(looks_like_iso8601(&resolve_timestamp(&a))); + } + + #[test] + fn test_resolve_timestamp_passes_through_supplied() { + let a = args(&["quipu", "knot", "f.ttl", "--timestamp", "2026-07-13T12:00:00Z"]); + assert_eq!(resolve_timestamp(&a), "2026-07-13T12:00:00Z"); + } + + #[test] + fn test_looks_like_iso8601() { + assert!(looks_like_iso8601("2026-07-13")); + assert!(looks_like_iso8601("2026-07-13T12:00:00Z")); + assert!(!looks_like_iso8601("2026/07/13")); // wrong separators + assert!(!looks_like_iso8601("13-07-2026")); // digits where dashes expected + assert!(!looks_like_iso8601("2026-07")); // too short + assert!(!looks_like_iso8601("not-a-date")); + } +} diff --git a/src/cli_commands.rs b/src/cli_commands.rs index c3d939a..2df634b 100644 --- a/src/cli_commands.rs +++ b/src/cli_commands.rs @@ -4,18 +4,23 @@ use std::io::{self, BufRead, Read, Write}; use oxrdfio::RdfFormat; -use crate::cli::{chrono_now, format_value}; +use crate::cli::{chrono_now, flag_value, format_value, resolve_timestamp}; pub fn cmd_episode(args: &[String], db_path: &str) { let file_arg = match args.get(2) { Some(p) if !p.starts_with("--") => p.as_str(), _ => { - eprintln!("usage: quipu episode [--db ]"); + eprintln!("usage: quipu episode [--base-ns ] [--timestamp ] [--db ]"); eprintln!(" use - to read from stdin"); std::process::exit(1); } }; + // Override the namespace IRIs are minted in (quipu #28). Defaults to the + // built-in aegis namespace, letting non-aegis deployments use the episode + // abstraction instead of routing around it via verbatim-Turtle knot. + let base_ns = flag_value(args, "--base-ns").unwrap_or(quipu::namespace::DEFAULT_BASE_NS); + let json_str = if file_arg == "-" { let mut buf = String::new(); io::stdin() @@ -52,13 +57,8 @@ pub fn cmd_episode(args: &[String], db_path: &str) { } }; - let now = chrono_now(); - match quipu::ingest_episode( - &mut store, - &episode, - &now, - quipu::namespace::DEFAULT_BASE_NS, - ) { + let now = resolve_timestamp(args); + match quipu::ingest_episode(&mut store, &episode, &now, base_ns) { Ok((tx_id, count)) => { println!( "ingested episode \"{}\" -- {count} facts (tx {tx_id})", @@ -76,15 +76,12 @@ pub fn cmd_retract(args: &[String], db_path: &str) { let entity_iri = match args.get(2) { Some(iri) if !iri.starts_with("--") => iri.as_str(), _ => { - eprintln!("usage: quipu retract [--predicate ] [--db ]"); + eprintln!("usage: quipu retract [--predicate ] [--timestamp ] [--db ]"); std::process::exit(1); } }; - let predicate_iri = args - .windows(2) - .find(|w| w[0] == "--predicate") - .map(|w| w[1].as_str()); + let predicate_iri = flag_value(args, "--predicate"); let mut store = match quipu::Store::open(db_path) { Ok(s) => s, @@ -96,7 +93,7 @@ pub fn cmd_retract(args: &[String], db_path: &str) { let mut input = serde_json::json!({ "entity": entity_iri, - "timestamp": chrono_now(), + "timestamp": resolve_timestamp(args), }); if let Some(pred) = predicate_iri { input["predicate"] = serde_json::json!(pred); From 5e2366c0908c7a6eb2af5a318b4e7eb71b84a02a Mon Sep 17 00:00:00 2001 From: strider Date: Mon, 13 Jul 2026 22:23:30 -0400 Subject: [PATCH 2/2] style(cli): rustfmt the new flag-parsing code Satisfies the fmt gate (cargo fmt --check / pre-commit cargo-fmt hook); no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01VsuPDxSUjMYqHvQyPvSAPW --- src/cli.rs | 27 ++++++++++++++++++++------- src/cli_commands.rs | 8 ++++++-- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index cca01e0..724a567 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -27,7 +27,9 @@ pub fn resolve_timestamp(args: &[String]) -> String { match flag_value(args, "--timestamp") { Some(ts) => { if !looks_like_iso8601(ts) { - eprintln!("error: --timestamp must be ISO-8601 (e.g. 2026-07-13T12:00:00Z), got: {ts}"); + eprintln!( + "error: --timestamp must be ISO-8601 (e.g. 2026-07-13T12:00:00Z), got: {ts}" + ); std::process::exit(1); } ts.to_string() @@ -41,17 +43,22 @@ pub fn resolve_timestamp(args: &[String]) -> String { /// obviously wrong values before they reach the store's `valid_from`. fn looks_like_iso8601(ts: &str) -> bool { ts.len() >= 10 - && ts.as_bytes()[..10] - .iter() - .enumerate() - .all(|(i, b)| if i == 4 || i == 7 { *b == b'-' } else { b.is_ascii_digit() }) + && ts.as_bytes()[..10].iter().enumerate().all(|(i, b)| { + if i == 4 || i == 7 { + *b == b'-' + } else { + b.is_ascii_digit() + } + }) } pub fn cmd_knot(args: &[String], db_path: &str) { let file_path = match args.get(2) { Some(p) if !p.starts_with("--") => p.as_str(), _ => { - eprintln!("usage: quipu knot [--shapes ] [--timestamp ] [--db ]"); + eprintln!( + "usage: quipu knot [--shapes ] [--timestamp ] [--db ]" + ); std::process::exit(1); } }; @@ -638,7 +645,13 @@ mod tests { #[test] fn test_resolve_timestamp_passes_through_supplied() { - let a = args(&["quipu", "knot", "f.ttl", "--timestamp", "2026-07-13T12:00:00Z"]); + let a = args(&[ + "quipu", + "knot", + "f.ttl", + "--timestamp", + "2026-07-13T12:00:00Z", + ]); assert_eq!(resolve_timestamp(&a), "2026-07-13T12:00:00Z"); } diff --git a/src/cli_commands.rs b/src/cli_commands.rs index 2df634b..66582fe 100644 --- a/src/cli_commands.rs +++ b/src/cli_commands.rs @@ -10,7 +10,9 @@ pub fn cmd_episode(args: &[String], db_path: &str) { let file_arg = match args.get(2) { Some(p) if !p.starts_with("--") => p.as_str(), _ => { - eprintln!("usage: quipu episode [--base-ns ] [--timestamp ] [--db ]"); + eprintln!( + "usage: quipu episode [--base-ns ] [--timestamp ] [--db ]" + ); eprintln!(" use - to read from stdin"); std::process::exit(1); } @@ -76,7 +78,9 @@ pub fn cmd_retract(args: &[String], db_path: &str) { let entity_iri = match args.get(2) { Some(iri) if !iri.starts_with("--") => iri.as_str(), _ => { - eprintln!("usage: quipu retract [--predicate ] [--timestamp ] [--db ]"); + eprintln!( + "usage: quipu retract [--predicate ] [--timestamp ] [--db ]" + ); std::process::exit(1); } };