Skip to main content

sqlite_graphrag/parsers/
mod.rs

1//! Input format parsers (timestamp, range validators).
2
3use chrono::DateTime;
4use unicode_normalization::UnicodeNormalization;
5
6/// Accepts a Unix epoch (integer >= 0) or RFC 3339 timestamp and returns the Unix epoch.
7pub fn parse_expected_updated_at(s: &str) -> Result<i64, String> {
8    if let Ok(secs) = s.parse::<i64>() {
9        if secs >= 0 {
10            return Ok(secs);
11        }
12    }
13    DateTime::parse_from_rfc3339(s)
14        .map(|dt| dt.timestamp())
15        .map_err(|e| {
16            format!(
17                "value must be a Unix epoch (integer >= 0) or RFC 3339 (e.g. 2026-04-19T12:00:00Z): {e}"
18            )
19        })
20}
21
22/// Shared range check behind every numeric read-path argument.
23///
24/// Until v1.2.7 this logic existed once, for `-k`, and the other twelve numeric
25/// arguments had no validator at all. `related --limit` was the sharp end of
26/// that: it drove `Vec::with_capacity(limit)` before any data could bound it,
27/// so an absurd value aborted the process on allocation instead of returning
28/// the exit code this crate reserves for memory pressure. Every ceiling now
29/// lives in `crate::constants` and every public parser below is one line, so a
30/// new bounded argument costs a wrapper rather than a copy of this function.
31fn parse_usize_in_range(s: &str, lo: usize, hi: usize) -> Result<usize, String> {
32    let value: usize = s
33        .parse()
34        .map_err(|_| format!("'{s}' is not a valid non-negative integer"))?;
35    if !(lo..=hi).contains(&value) {
36        // The argument is not named here on purpose: Clap already prefixes the
37        // message with `invalid value '...' for '--limit <LIMIT>'`, so naming a
38        // field would contradict that prefix wherever a parser is shared.
39        return Err(format!(
40            "must be between {lo} and {hi} (inclusive); got {value}"
41        ));
42    }
43    Ok(value)
44}
45
46/// Validates `-k`/`--k` on every retrieval command.
47///
48/// The upper bound matches the historical `sqlite-vec` knn limit; values above
49/// it used to surface a leaky engine error such as `k value in knn query too
50/// large, provided 10000 and the limit is 4096`. Validating at parse time turns
51/// the failure into a clean Clap error before any database work.
52pub fn parse_k_range(s: &str) -> Result<usize, String> {
53    parse_usize_in_range(s, 1, crate::constants::K_QUERY_RANGE_MAX)
54}
55
56/// Validates `--limit` on the commands that page over stored rows.
57///
58/// A looser ceiling than [`parse_k_range`] because `export --limit` ships a
59/// default of 100_000. These values reach SQLite as a `LIMIT` clause, where the
60/// row count already bounds the work, so the check rejects absurd input rather
61/// than guarding memory.
62pub fn parse_list_limit_range(s: &str) -> Result<usize, String> {
63    parse_usize_in_range(s, 1, crate::constants::K_LIST_LIMIT_MAX)
64}
65
66/// Validates `--max-hops` and `--depth` where the argument is a `usize`.
67pub fn parse_hops_range_usize(s: &str) -> Result<usize, String> {
68    parse_usize_in_range(s, 1, crate::constants::K_MAX_HOPS_CEILING as usize)
69}
70
71/// Validates `--max-hops` and `--depth` where the argument is a `u32`.
72pub fn parse_hops_range_u32(s: &str) -> Result<u32, String> {
73    parse_usize_in_range(s, 1, crate::constants::K_MAX_HOPS_CEILING as usize).map(|v| v as u32)
74}
75
76/// Validates `enrich --quality-sample`.
77///
78/// Zero is ADMITTED and is not a degenerate case: `status.rs` treats
79/// `sample_n == 0` as "skip the quality sample entirely", so the lower bound of
80/// the shared [`parse_k_range`] would reject a documented, meaningful value.
81///
82/// The upper bound guards memory rather than the engine.
83/// `quality_sample::sample_entity_description_quality` sizes a `Vec<f64>` from
84/// this number before a single row is read, so an absurd value aborted the
85/// process on allocation instead of returning the exit code this crate reserves
86/// for memory pressure — the same failure `related --limit` had under
87/// GAP-SG-213, in a field that gate could not see because it is declared as
88/// `Option<usize>` rather than `usize`.
89pub fn parse_quality_sample_range(s: &str) -> Result<usize, String> {
90    parse_usize_in_range(s, 0, crate::constants::K_QUERY_RANGE_MAX)
91}
92
93/// Validates `deep-research --max-sub-queries`.
94///
95/// This ceiling guards spend rather than memory: every sub-query is a separate
96/// REST round trip, so an unbounded value bills an unbounded fan-out.
97pub fn parse_sub_queries_range(s: &str) -> Result<usize, String> {
98    parse_usize_in_range(s, 1, crate::constants::K_MAX_SUB_QUERIES_CEILING)
99}
100
101/// Flexible boolean parser for Clap env var integration.
102///
103/// Accepts common truthy/falsy conventions used in shell environments:
104/// truthy: `1`, `true`, `yes`, `on` (case-insensitive)
105/// falsy: `0`, `false`, `no`, `off`, empty string (case-insensitive)
106pub fn parse_bool_flexible(s: &str) -> Result<bool, String> {
107    match s.to_lowercase().as_str() {
108        "1" | "true" | "yes" | "on" => Ok(true),
109        "0" | "false" | "no" | "off" | "" => Ok(false),
110        _ => Err(format!(
111            "invalid boolean value '{s}': expected true/false/1/0/yes/no/on/off"
112        )),
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn accepts_unix_epoch() {
122        assert_eq!(parse_expected_updated_at("1700000000").unwrap(), 1700000000);
123    }
124
125    #[test]
126    fn accepts_zero() {
127        assert_eq!(parse_expected_updated_at("0").unwrap(), 0);
128    }
129
130    #[test]
131    fn accepts_rfc_3339_utc() {
132        let result = parse_expected_updated_at("2020-01-01T00:00:00Z");
133        assert!(result.is_ok());
134        assert_eq!(result.unwrap(), 1577836800);
135    }
136
137    #[test]
138    fn accepts_rfc_3339_with_offset() {
139        let result = parse_expected_updated_at("2026-04-19T12:00:00+00:00");
140        assert!(result.is_ok());
141    }
142
143    #[test]
144    fn rejects_invalid_string() {
145        assert!(parse_expected_updated_at("bananas").is_err());
146    }
147
148    #[test]
149    fn rejects_negative() {
150        let err = parse_expected_updated_at("-1");
151        assert!(err.is_err());
152    }
153
154    #[test]
155    fn error_message_mentions_format() {
156        let msg = parse_expected_updated_at("invalid").unwrap_err();
157        assert!(msg.contains("RFC 3339") || msg.contains("Unix epoch"));
158    }
159
160    #[test]
161    fn k_accepts_valid_range_endpoints() {
162        assert_eq!(parse_k_range("1").unwrap(), 1);
163        assert_eq!(parse_k_range("4096").unwrap(), 4096);
164        assert_eq!(parse_k_range("10").unwrap(), 10);
165    }
166
167    #[test]
168    fn k_rejects_zero() {
169        let msg = parse_k_range("0").unwrap_err();
170        assert!(msg.contains("between 1 and 4096"));
171    }
172
173    #[test]
174    fn k_rejects_above_limit() {
175        let msg = parse_k_range("10000").unwrap_err();
176        assert!(msg.contains("between 1 and 4096"));
177    }
178
179    #[test]
180    fn k_rejects_non_integer() {
181        let msg = parse_k_range("abc").unwrap_err();
182        assert!(msg.contains("not a valid"));
183    }
184
185    #[test]
186    fn k_rejects_negative() {
187        // usize parser fails on negatives before range check
188        assert!(parse_k_range("-5").is_err());
189    }
190
191    #[test]
192    fn bool_flexible_truthy() {
193        for v in &["1", "true", "True", "TRUE", "yes", "Yes", "on", "ON"] {
194            assert!(parse_bool_flexible(v).unwrap(), "should be true: {v}");
195        }
196    }
197
198    #[test]
199    fn bool_flexible_falsy() {
200        for v in &["0", "false", "False", "FALSE", "no", "No", "off", "OFF", ""] {
201            assert!(!parse_bool_flexible(v).unwrap(), "should be false: {v}");
202        }
203    }
204
205    #[test]
206    fn bool_flexible_rejects_invalid() {
207        assert!(parse_bool_flexible("banana").is_err());
208        assert!(parse_bool_flexible("2").is_err());
209        assert!(parse_bool_flexible("nope").is_err());
210    }
211}
212
213/// The 12 well-known relation types, in the ONE spelling this crate stores.
214///
215/// v1.2.8: kebab-case. The list used to be snake_case while the JSON Schema
216/// handed to the extraction model (`enrich::schemas`) declared the same twelve
217/// names in kebab-case, and `enrich::extraction_body` persisted the model's
218/// answer verbatim. The result was a store split across two spellings of the
219/// same relation — measured at 67 651 kebab edges against 3 578 snake ones over
220/// three production databases, so the spelling this constant called canonical
221/// was the one 5% of the data used.
222///
223/// The split was invisible because every read filter normalises before a
224/// LITERAL `WHERE`: `related --relation applies-to` returned zero rows, exit 0,
225/// on a hub that has `applies-to` edges. Instruments read the wrong scale for
226/// the same reason — `health.applies_to_ratio` reported 0.0085% where the true
227/// share is 17.8%, a factor of 2098.
228///
229/// Only the three multi-word relations can differ at all, and those are exactly
230/// the ones carrying hierarchy: `applies-to`, `depends-on`, `tracked-in`.
231///
232/// Non-canonical relations are accepted but emit a `tracing::warn!`.
233pub const CANONICAL_RELATIONS: &[&str] = &[
234    "applies-to",
235    "uses",
236    "depends-on",
237    "causes",
238    "fixes",
239    "contradicts",
240    "supports",
241    "follows",
242    "related",
243    "mentions",
244    "replaces",
245    "tracked-in",
246];
247
248/// The generic relation, named once so consumers stop repeating the literal.
249///
250/// `enrich::predicates`, `enrich::scan::relationships` and `health` each held
251/// their own copy of this string, and each held the snake_case one. A literal
252/// repeated in four places drifts in four places.
253pub const GENERIC_RELATION: &str = "applies-to";
254
255/// Returns `true` when the relation is one of the 12 canonical types.
256pub fn is_canonical_relation(s: &str) -> bool {
257    CANONICAL_RELATIONS.contains(&s)
258}
259
260/// Normalizes a relation string: lowercase + underscores to hyphens.
261///
262/// v1.2.8 reversed the direction along with [`CANONICAL_RELATIONS`]. Callers
263/// keep passing either spelling; what changed is which one survives to SQL.
264pub fn normalize_relation(s: &str) -> String {
265    s.to_lowercase().replace('_', "-")
266}
267
268/// Normalizes an entity name to kebab-case ASCII.
269///
270/// Applies NFKD decomposition, filters to ASCII (transliterating by dropping
271/// diacritical combining marks), lowercases, converts spaces and underscores
272/// to hyphens, collapses consecutive hyphens, and trims leading/trailing hyphens.
273///
274/// # Examples
275///
276/// ```
277/// use sqlite_graphrag::parsers::normalize_entity_name;
278///
279/// assert_eq!(normalize_entity_name("Alice Martins"), "alice-martins");
280/// assert_eq!(normalize_entity_name("CANONICAL_RELATIONS"), "canonical-relations");
281/// assert_eq!(normalize_entity_name("  hello  world  "), "hello-world");
282/// assert_eq!(normalize_entity_name("alice-martins"), "alice-martins"); // idempotent
283/// ```
284pub fn normalize_entity_name(s: &str) -> String {
285    // NFKD: decompose precomposed characters into base + combining marks.
286    // Then keep only ASCII characters, effectively stripping diacritics.
287    let ascii: String = s.nfkd().filter(|c| c.is_ascii()).collect();
288    // Lowercase, then replace spaces and underscores with hyphens.
289    let hyphenated: String = ascii
290        .to_lowercase()
291        .chars()
292        .map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
293        .collect();
294    // Collapse consecutive hyphens and trim from both ends.
295    let mut result = String::with_capacity(hyphenated.len());
296    let mut prev_was_hyphen = false;
297    for ch in hyphenated.chars() {
298        if ch == '-' {
299            if !prev_was_hyphen {
300                result.push('-');
301            }
302            prev_was_hyphen = true;
303        } else {
304            result.push(ch);
305            prev_was_hyphen = false;
306        }
307    }
308    result.trim_matches('-').to_string()
309}
310
311/// Validates that a NORMALIZED relation matches `^[a-z][a-z0-9-]*$`.
312///
313/// v1.2.8: hyphen replaced underscore here together with [`normalize_relation`].
314/// The pair must agree, and before they did this function rejected the very
315/// spelling the crate was persisting: `applies-to` failed validation while
316/// 50 346 rows of it sat in one database, because the write path that produced
317/// them called neither this nor the normaliser.
318///
319/// Takes the normalised form. Callers that hold raw input run it through
320/// [`normalize_relation`] or [`parse_relation`] first.
321pub fn validate_relation_format(s: &str) -> Result<(), String> {
322    if s.is_empty() {
323        return Err("relation must not be empty".to_string());
324    }
325    if !s.as_bytes()[0].is_ascii_lowercase() {
326        return Err(format!(
327            "relation must start with a lowercase letter, got '{s}'"
328        ));
329    }
330    if !s
331        .bytes()
332        .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
333    {
334        return Err(format!(
335            "relation must contain only lowercase letters, digits and hyphens, got '{s}'"
336        ));
337    }
338    Ok(())
339}
340
341/// Maps an arbitrary relation label to its canonical form, never producing a
342/// non-canonical value (GAP-SG-48).
343///
344/// Relation handling used to be inconsistent: non-canonical relations were
345/// accepted raw (with only a `WARN`) while non-canonical entity types were
346/// rejected outright. This unifies the policy — extraction never persists a
347/// label outside the canonical vocabulary. Known aliases are rewritten via a
348/// fixed table; values that are already canonical pass through unchanged;
349/// anything else falls back to the generic `related`.
350///
351/// Alias table (mirrors the project's canonical relation map):
352/// `adds`/`creates` → `causes`, `implements` → `supports`,
353/// `blocks` → `contradicts`, `tested-by` → `related`, `part-of` → `applies-to`.
354///
355/// The arms are written in kebab-case because [`normalize_relation`] hands this
356/// `match` kebab-case: an arm spelled `part_of` would be unreachable.
357pub fn map_to_canonical_relation(s: &str) -> String {
358    let normalized = normalize_relation(s);
359    if is_canonical_relation(&normalized) {
360        return normalized;
361    }
362    match normalized.as_str() {
363        "adds" | "creates" => "causes",
364        "implements" => "supports",
365        "blocks" => "contradicts",
366        "tested-by" | "related-to" => "related",
367        "part-of" => "applies-to",
368        // Any other non-canonical relation folds onto the generic canonical
369        // kind rather than being persisted raw.
370        _ => "related",
371    }
372    .to_string()
373}
374
375/// Emits a `tracing::warn!` when the relation is not in [`CANONICAL_RELATIONS`].
376pub fn warn_if_non_canonical(relation: &str) {
377    if !is_canonical_relation(relation) {
378        tracing::warn!(target: "parsers",
379            relation,
380            "non-canonical relation accepted; consider using a well-known value"
381        );
382    }
383}
384
385/// Clap `value_parser` for `--relation`: normalizes and validates format.
386///
387/// Accepts any kebab-case or snake_case string. Non-canonical values are
388/// accepted at parse time; the warning is emitted at command execution.
389pub fn parse_relation(s: &str) -> Result<String, String> {
390    let normalized = normalize_relation(s);
391    validate_relation_format(&normalized)?;
392    Ok(normalized)
393}
394
395#[cfg(test)]
396mod relation_tests {
397    use super::*;
398
399    #[test]
400    fn canonical_relations_all_valid() {
401        for r in CANONICAL_RELATIONS {
402            assert!(
403                validate_relation_format(r).is_ok(),
404                "canonical relation '{r}' should be valid"
405            );
406        }
407    }
408
409    // v1.2.8: the expectations below changed direction because the CONTRACT
410    // changed, by decision, not to make a red test green. The crate now stores
411    // kebab-case, which is the spelling 95% of the existing rows already used
412    // and the one every prompt and document already taught; snake_case was the
413    // spelling only this constant believed in.
414    #[test]
415    fn normalize_converts_underscores_and_uppercase() {
416        assert_eq!(normalize_relation("Depends_On"), "depends-on");
417        assert_eq!(normalize_relation("TESTED_BY"), "tested-by");
418        assert_eq!(normalize_relation("uses"), "uses");
419    }
420
421    #[test]
422    fn validate_rejects_empty() {
423        assert!(validate_relation_format("").is_err());
424    }
425
426    #[test]
427    fn validate_rejects_digit_start() {
428        assert!(validate_relation_format("123abc").is_err());
429    }
430
431    #[test]
432    fn validate_rejects_spaces() {
433        assert!(validate_relation_format("has spaces").is_err());
434    }
435
436    #[test]
437    fn validate_accepts_custom_relations() {
438        // Takes the NORMALISED form, so the multi-word cases arrive hyphenated.
439        assert!(validate_relation_format("implements").is_ok());
440        assert!(validate_relation_format("tested-by").is_ok());
441        assert!(validate_relation_format("part-of").is_ok());
442        assert!(validate_relation_format("blocks").is_ok());
443    }
444
445    /// The normaliser and the validator must agree, in both directions.
446    ///
447    /// They disagreed before v1.2.8, and that disagreement is the whole defect:
448    /// the validator demanded underscores while the bulk write path stored
449    /// hyphens, so the crate rejected as malformed the exact spelling it held
450    /// 67 651 rows of. Nothing compared the two, so nothing said so.
451    #[test]
452    fn normaliser_output_always_passes_the_validator() {
453        for raw in [
454            "Applies-To",
455            "applies_to",
456            "DEPENDS_ON",
457            "depends-on",
458            "tracked_in",
459            "uses",
460            "tested-by",
461            "part_of",
462        ] {
463            let normalized = normalize_relation(raw);
464            assert!(
465                validate_relation_format(&normalized).is_ok(),
466                "normalize_relation({raw:?}) produced {normalized:?}, which the                  validator rejects — the two disagree about the stored form"
467            );
468        }
469        for rel in CANONICAL_RELATIONS {
470            assert_eq!(
471                &normalize_relation(rel),
472                rel,
473                "normalize_relation is not idempotent on the canonical relation                  {rel:?}, so the constant names a form the crate never stores"
474            );
475            assert!(validate_relation_format(rel).is_ok());
476        }
477    }
478
479    #[test]
480    fn parse_relation_normalizes_and_validates() {
481        assert_eq!(parse_relation("Tested_By").unwrap(), "tested-by");
482        assert_eq!(parse_relation("Tested-By").unwrap(), "tested-by");
483        assert_eq!(parse_relation("uses").unwrap(), "uses");
484        assert!(parse_relation("").is_err());
485    }
486
487    #[test]
488    fn is_canonical_detects_known() {
489        assert!(is_canonical_relation("uses"));
490        assert!(is_canonical_relation("applies-to"));
491        // The snake spelling is no longer canonical, and saying so out loud is
492        // the point: a database written by an older binary holds it, and only
493        // the reader tolerates it — the writer never produces it again.
494        assert!(!is_canonical_relation("applies_to"));
495        assert!(!is_canonical_relation("implements"));
496        assert!(!is_canonical_relation("blocks"));
497    }
498
499    #[test]
500    fn map_to_canonical_relation_passes_through_canonical() {
501        assert_eq!(map_to_canonical_relation("uses"), "uses");
502        assert_eq!(map_to_canonical_relation("Applies-To"), "applies-to");
503        assert_eq!(map_to_canonical_relation("DEPENDS_ON"), "depends-on");
504        // Both spellings converge on the stored one, which is what lets the
505        // persistence boundary canonicalise without rejecting any caller.
506        assert_eq!(map_to_canonical_relation("applies_to"), "applies-to");
507        assert_eq!(map_to_canonical_relation("tracked_in"), "tracked-in");
508    }
509
510    #[test]
511    fn map_to_canonical_relation_rewrites_known_aliases() {
512        // GAP-SG-48: part-of was previously accepted raw with only a WARN.
513        assert_eq!(map_to_canonical_relation("part-of"), "applies-to");
514        assert_eq!(map_to_canonical_relation("part_of"), "applies-to");
515        assert_eq!(map_to_canonical_relation("implements"), "supports");
516        assert_eq!(map_to_canonical_relation("blocks"), "contradicts");
517        assert_eq!(map_to_canonical_relation("adds"), "causes");
518        assert_eq!(map_to_canonical_relation("creates"), "causes");
519        assert_eq!(map_to_canonical_relation("tested-by"), "related");
520        assert_eq!(map_to_canonical_relation("related_to"), "related");
521        assert_eq!(map_to_canonical_relation("related-to"), "related");
522    }
523
524    #[test]
525    fn map_to_canonical_relation_unknown_folds_to_related() {
526        assert_eq!(map_to_canonical_relation("some-weird-relation"), "related");
527        // Output is always itself canonical.
528        assert!(is_canonical_relation(&map_to_canonical_relation("xyz")));
529    }
530}
531
532#[cfg(test)]
533mod entity_name_tests {
534    use super::*;
535
536    #[test]
537    fn strips_diacritics_from_accented_name() {
538        assert_eq!(normalize_entity_name("Alice Martins"), "alice-martins");
539    }
540
541    #[test]
542    fn strips_diacritics_unicode_accents() {
543        // `é → e, ã → a, ç → c`
544        assert_eq!(normalize_entity_name("São Paulo"), "sao-paulo");
545        assert_eq!(normalize_entity_name("Ünit Tëst"), "unit-test");
546    }
547
548    #[test]
549    fn converts_spaces_to_hyphens() {
550        assert_eq!(normalize_entity_name("hello world"), "hello-world");
551        assert_eq!(normalize_entity_name("  hello  world  "), "hello-world");
552    }
553
554    #[test]
555    fn converts_underscores_to_hyphens() {
556        assert_eq!(normalize_entity_name("hello_world"), "hello-world");
557        assert_eq!(
558            normalize_entity_name("CANONICAL_RELATIONS"),
559            "canonical-relations"
560        );
561    }
562
563    #[test]
564    fn all_caps_becomes_lowercase_kebab() {
565        assert_eq!(
566            normalize_entity_name("CANONICAL_RELATIONS"),
567            "canonical-relations"
568        );
569        assert_eq!(normalize_entity_name("MY_ENTITY_NAME"), "my-entity-name");
570    }
571
572    #[test]
573    fn idempotent_on_already_normalized() {
574        let name = "alice-martins";
575        assert_eq!(normalize_entity_name(name), name);
576        let name2 = "canonical-relations";
577        assert_eq!(normalize_entity_name(name2), name2);
578    }
579
580    #[test]
581    fn collapses_consecutive_hyphens() {
582        assert_eq!(normalize_entity_name("foo--bar"), "foo-bar");
583        assert_eq!(normalize_entity_name("foo - bar"), "foo-bar");
584    }
585
586    #[test]
587    fn trims_leading_trailing_hyphens() {
588        assert_eq!(normalize_entity_name("-foo-"), "foo");
589        assert_eq!(normalize_entity_name("--hello--"), "hello");
590    }
591
592    #[test]
593    fn empty_or_only_separators_returns_empty() {
594        assert_eq!(normalize_entity_name(""), "");
595        assert_eq!(normalize_entity_name("---"), "");
596    }
597
598    #[test]
599    fn normalizes_dots_slashes_and_punctuation() {
600        assert_eq!(normalize_entity_name("lei-14.478/2022"), "lei-14-478-2022");
601        assert_eq!(normalize_entity_name("src/main.rs"), "src-main-rs");
602        assert_eq!(normalize_entity_name("user@domain.com"), "user-domain-com");
603        assert_eq!(normalize_entity_name("v1.0.66"), "v1-0-66");
604        assert_eq!(normalize_entity_name("key:value"), "key-value");
605    }
606}