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..724a567 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -9,19 +9,61 @@ 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 +121,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 +612,56 @@ 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..66582fe 100644 --- a/src/cli_commands.rs +++ b/src/cli_commands.rs @@ -4,18 +4,25 @@ 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 +59,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 +78,14 @@ 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 +97,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);