Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/book/src/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>` | SHACL shapes file for write-time validation |
| `--timestamp <ISO-8601>` | `valid_from` for the facts (default: now). Supply the source event time when ingesting history |

Alias: `load`

Expand Down Expand Up @@ -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 <IRI>` | Namespace to mint entity IRIs in (default: the built-in aegis namespace). Lets non-aegis deployments use the episode abstraction |
| `--timestamp <ISO-8601>` | `valid_from` for the facts (default: now) |

### `quipu retract <entity-IRI>`

Retract facts for an entity.
Expand All @@ -87,6 +96,7 @@ quipu retract "http://example.org/alice" --predicate "http://example.org/email"
| Flag | Description |
|------|-------------|
| `--predicate <IRI>` | Only retract facts with this predicate |
| `--timestamp <ISO-8601>` | Transaction valid-time for the retraction (default: now) |

### `quipu shapes`

Expand Down
107 changes: 101 additions & 6 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file.ttl> [--shapes <shapes.ttl>] [--db <path>]");
eprintln!(
"usage: quipu knot <file.ttl> [--shapes <shapes.ttl>] [--timestamp <ISO-8601>] [--db <path>]"
);
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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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<String> {
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"));
}
}
31 changes: 16 additions & 15 deletions src/cli_commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file.json> [--db <path>]");
eprintln!(
"usage: quipu episode <file.json> [--base-ns <IRI>] [--timestamp <ISO-8601>] [--db <path>]"
);
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()
Expand Down Expand Up @@ -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})",
Expand All @@ -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 <entity-IRI> [--predicate <IRI>] [--db <path>]");
eprintln!(
"usage: quipu retract <entity-IRI> [--predicate <IRI>] [--timestamp <ISO-8601>] [--db <path>]"
);
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,
Expand All @@ -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);
Expand Down
Loading