1use super::{select, CatalogueSnapshot, Harness, Pin, Release, Selection, SkillProfile};
5use anyhow::{bail, Context, Result};
6use serde_json::{json, Value};
7use std::collections::{BTreeMap, BTreeSet};
8
9const NS: &str = "urn:quipu:skills:";
10const RDF_TYPE: &str = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type";
11const STRING: &str = "http://www.w3.org/2001/XMLSchema#string";
12const BOOLEAN: &str = "http://www.w3.org/2001/XMLSchema#boolean";
13const MAX_ROWS: usize = 8192;
14
15#[derive(Debug)]
17pub struct Plan {
18 profile: String,
19 transaction: u64,
20 request: Value,
21}
22
23fn iri(value: &str) -> bool {
24 let Some((scheme, rest)) = value.split_once(':') else {
25 return false;
26 };
27 !rest.is_empty()
28 && value.len() <= 2048
29 && scheme
30 .as_bytes()
31 .first()
32 .is_some_and(u8::is_ascii_alphabetic)
33 && scheme
34 .bytes()
35 .all(|b| b.is_ascii_alphanumeric() || b"+-.".contains(&b))
36 && !value
37 .chars()
38 .any(|c| c.is_whitespace() || c.is_control() || "<>\"{}|^`\\".contains(c))
39}
40
41pub fn plan(profile: &str, graph: &str, transaction: u64) -> Result<Plan> {
43 if !iri(profile) || !iri(graph) || transaction == 0 || transaction > i64::MAX as u64 {
44 bail!("invalid explicit catalogue query pin");
45 }
46 let query = format!(
49 r#"PREFIX s: <{NS}>
50SELECT ?record ?subject ?predicate (STR(?value) AS ?text)
51 (IF(isIRI(?value), "iri", IF(isLiteral(?value), "literal", "blank")) AS ?kind)
52 (IF(isLiteral(?value), STR(DATATYPE(?value)), "") AS ?datatype)
53FROM <{graph}>
54WHERE {{
55 {{ BIND(<{profile}> AS ?subject) ?subject ?predicate ?value . BIND("profile" AS ?record) }}
56 UNION {{ <{profile}> s:pin ?subject . ?subject ?predicate ?value . BIND("pin" AS ?record) }}
57 UNION {{ <{profile}> s:pin ?pin . ?pin s:skill ?identity ; s:shareId ?share ; s:version ?version .
58 ?subject a s:Release ; s:skill ?identity ; s:shareId ?share ; s:version ?version ; ?predicate ?value . BIND("release" AS ?record) }}
59 FILTER(isIRI(?subject))
60}} LIMIT 8193"#
61 );
62 Ok(Plan {
63 profile: profile.into(),
64 transaction,
65 request: json!({"query": query, "tx": transaction, "verbose": true}),
66 })
67}
68
69type Object = (String, String, String); #[derive(Default)]
71struct Record(BTreeMap<String, BTreeSet<Object>>);
72impl Record {
73 fn values(&self, property: &str, kind: &str, datatype: &str) -> Result<Vec<String>> {
74 let key = if property == RDF_TYPE {
75 property.to_owned()
76 } else {
77 format!("{NS}{property}")
78 };
79 self.0
80 .get(&key)
81 .context("required catalogue property missing")?
82 .iter()
83 .map(|(k, v, d)| {
84 if k != kind || d != datatype || (kind == "iri" && !iri(v)) {
85 bail!("wrong catalogue RDF term kind");
86 }
87 Ok(v.clone())
88 })
89 .collect()
90 }
91 fn one(&self, property: &str, kind: &str, datatype: &str) -> Result<String> {
92 let values = self.values(property, kind, datatype)?;
93 if values.len() != 1 {
94 bail!("ambiguous catalogue property");
95 }
96 Ok(values[0].clone())
97 }
98 fn typed(&self, class: &str) -> Result<()> {
99 if !self
100 .values(RDF_TYPE, "iri", "")?
101 .contains(&format!("{NS}{class}"))
102 {
103 bail!("catalogue record has no explicit required type");
104 }
105 Ok(())
106 }
107 fn pin(&self) -> Result<Pin> {
108 Ok(Pin {
109 skill: self.one("skill", "iri", "")?,
110 share_id: self.one("shareId", "literal", STRING)?,
111 version: self.one("version", "literal", STRING)?,
112 })
113 }
114 fn harnesses(&self) -> Result<Vec<Harness>> {
115 self.values("harness", "literal", STRING)?
116 .iter()
117 .map(|s| match s.as_str() {
118 "claude" => Ok(Harness::Claude),
119 "codex" => Ok(Harness::Codex),
120 _ => anyhow::bail!("unsupported catalogue harness"),
121 })
122 .collect()
123 }
124}
125impl Plan {
126 pub fn request(&self) -> &Value {
128 &self.request
129 }
130
131 pub fn decode(&self, bytes: &[u8]) -> Result<Selection> {
134 if bytes.len() > 4 * 1024 * 1024 {
135 bail!("catalogue response exceeds byte budget");
136 }
137 let response: Value = serde_json::from_slice(bytes)?;
138 let rows = response["rows"]
139 .as_array()
140 .context("catalogue response has no rows")?;
141 if response["truncated"] != false
142 || response["count"].as_u64() != Some(rows.len() as u64)
143 || rows.len() > MAX_ROWS
144 {
145 bail!("catalogue response coverage is unknown");
146 }
147 let expected: BTreeSet<_> = ["record", "subject", "predicate", "text", "kind", "datatype"]
148 .into_iter()
149 .collect();
150 let variables: BTreeSet<_> = response["variables"]
151 .as_array()
152 .context("missing catalogue columns")?
153 .iter()
154 .map(|v| v.as_str().context("invalid catalogue column"))
155 .collect::<Result<_>>()?;
156 if variables != expected {
157 bail!("unexpected catalogue query columns");
158 }
159 let mut records: BTreeMap<(String, String), Record> = BTreeMap::new();
160 for row in rows {
161 let field = |key: &str| {
162 row[key]
163 .as_str()
164 .context("malformed catalogue row")
165 .map(str::to_owned)
166 };
167 let record = field("record")?;
168 let subject = field("subject")?;
169 let predicate = field("predicate")?;
170 if !["profile", "pin", "release"].contains(&record.as_str())
171 || !iri(&subject)
172 || !iri(&predicate)
173 {
174 bail!("invalid catalogue record identity");
175 }
176 records
177 .entry((record, subject))
178 .or_default()
179 .0
180 .entry(predicate)
181 .or_default()
182 .insert((field("kind")?, field("text")?, field("datatype")?));
183 }
184 let profile = records
185 .remove(&("profile".into(), self.profile.clone()))
186 .context("selected profile absent")?;
187 profile.typed("Profile")?;
188 let public_only = match profile.one("publicOnly", "literal", BOOLEAN)?.as_str() {
189 "true" | "1" => true,
190 "false" | "0" => false,
191 _ => bail!("invalid public-only policy"),
192 };
193 let mut pins = Vec::new();
194 for id in profile.values("pin", "iri", "")? {
195 let pin = records
196 .remove(&("pin".into(), id))
197 .context("selected pin absent")?;
198 pin.typed("Pin")?;
199 pins.push(pin.pin()?);
200 }
201 let selected_keys: BTreeSet<_> = pins
202 .iter()
203 .map(|p| (&p.skill, &p.share_id, &p.version))
204 .collect();
205 let mut releases = Vec::new();
206 for ((kind, _), record) in records {
207 if kind != "release" {
208 bail!("unexpected or unselected catalogue record");
209 }
210 record.typed("Release")?;
211 let pin = record.pin()?;
212 if !selected_keys.contains(&(&pin.skill, &pin.share_id, &pin.version)) {
213 bail!("unselected catalogue release");
214 }
215 let public = match record.one("scope", "literal", STRING)?.as_str() {
216 "public" => true,
217 "internal" => false,
218 _ => bail!("unsupported catalogue scope"),
219 };
220 releases.push(Release {
221 pin,
222 name: record.one("name", "literal", STRING)?,
223 render_spec: record.one("renderSpec", "iri", "")?,
224 public,
225 harnesses: record.harnesses()?,
226 });
227 }
228 let profile = SkillProfile {
229 id: self.profile.clone(),
230 public_only,
231 harnesses: profile.harnesses()?,
232 pins,
233 };
234 select(
235 &profile,
236 &CatalogueSnapshot {
237 transaction: self.transaction,
238 complete: true,
239 releases,
240 },
241 )
242 }
243}
244
245#[cfg(test)]
246mod tests;