Skip to main content

hyalo_cli/commands/
lint.rs

1/// `hyalo lint` — validate frontmatter properties against the `.hyalo.toml` schema.
2///
3/// Reads each file's frontmatter, applies the type-specific schema (or the
4/// default schema if `type` is absent), and reports violations at two severity
5/// levels:
6///
7///   - **error**  — schema violation (missing required field, wrong value type,
8///     invalid enum value, failed pattern match)
9///   - **warn**   — soft issue (no `type` property, no `tags` property, property
10///     not declared in schema)
11///
12/// Exit code: 0 = clean, 1 = errors found, 2 = internal error.
13use std::collections::HashMap;
14use std::path::Path;
15
16use anyhow::{Context, Result};
17use indexmap::IndexMap;
18use regex::Regex;
19use serde::Serialize;
20use serde_json::Value;
21
22use hyalo_core::filename_template::FilenameTemplate;
23use hyalo_core::frontmatter::{read_frontmatter, write_frontmatter};
24use hyalo_core::schema::{self, PropertyConstraint, SchemaConfig, TypeSchema};
25use hyalo_core::util::is_iso8601_date;
26
27use crate::output::{CommandOutcome, Format, format_success};
28
29// ---------------------------------------------------------------------------
30// Types
31// ---------------------------------------------------------------------------
32
33/// Severity of a single lint violation.
34#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
35#[serde(rename_all = "snake_case")]
36pub enum Severity {
37    Error,
38    Warn,
39}
40
41impl std::fmt::Display for Severity {
42    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
43        match self {
44            Self::Error => f.write_str("error"),
45            Self::Warn => f.write_str("warn"),
46        }
47    }
48}
49
50/// A single lint violation found in a file.
51#[derive(Debug, Clone, Serialize)]
52pub struct Violation {
53    pub severity: Severity,
54    pub message: String,
55}
56
57/// Lint results for a single file.
58#[derive(Debug, Serialize)]
59pub struct FileLintResult {
60    pub file: String,
61    pub violations: Vec<Violation>,
62}
63
64/// A single auto-fix that was (or would be) applied.
65#[derive(Debug, Clone, Serialize)]
66pub struct FixAction {
67    /// Kind of fix: "insert-default", "fix-enum-typo", "normalize-date", "infer-type".
68    pub kind: String,
69    /// Frontmatter property affected.
70    pub property: String,
71    /// Old value (if any) — omitted for inserted properties.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub old: Option<String>,
74    /// New value applied (or previewed with --dry-run).
75    pub new: String,
76}
77
78/// Aggregated lint output.
79///
80/// The `files` field is renamed from the internal `results` to avoid a
81/// confusing `results.results` nesting once the CLI envelope wraps the payload.
82#[derive(Debug, Serialize)]
83pub struct LintOutput {
84    pub files: Vec<FileLintResult>,
85    /// Total number of violations found across all files.
86    pub total: usize,
87    /// Number of files that were checked.
88    pub files_checked: usize,
89    /// Fixes that were applied (or previewed) per file. Omitted when no
90    /// `--fix` run produced any changes.
91    #[serde(skip_serializing_if = "Vec::is_empty")]
92    pub fixes: Vec<FileFixResult>,
93    /// `true` when `--dry-run` was passed and fixes were not written.
94    #[serde(skip_serializing_if = "std::ops::Not::not")]
95    pub dry_run: bool,
96}
97
98/// Fixes applied to a single file.
99#[derive(Debug, Clone, Serialize)]
100pub struct FileFixResult {
101    pub file: String,
102    pub actions: Vec<FixAction>,
103}
104
105/// Summary counts returned to callers (e.g. `hyalo summary`).
106#[derive(Debug, Clone, Default)]
107pub struct LintCounts {
108    pub errors: usize,
109    pub warnings: usize,
110    /// Number of files with at least one violation.
111    pub files_with_issues: usize,
112}
113
114// ---------------------------------------------------------------------------
115// Public API
116// ---------------------------------------------------------------------------
117
118/// Run `hyalo lint` against a list of `(full_path, rel_path)` file pairs.
119///
120/// Returns the formatted output and the set of counts; the caller is
121/// responsible for translating counts into an exit code.
122pub fn lint_files(
123    files: &[(std::path::PathBuf, String)],
124    schema: &SchemaConfig,
125) -> Result<(CommandOutcome, LintCounts)> {
126    lint_files_with_options(files, schema, FixMode::Off)
127}
128
129/// Whether — and how — `lint_files_with_options` should apply auto-fixes.
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum FixMode {
132    /// Read-only: do not attempt to fix anything.
133    Off,
134    /// Apply fixes in memory and write them back to disk.
135    Apply,
136    /// Compute the fixes that would be applied but don't write any files.
137    DryRun,
138}
139
140/// Run lint with the given fix mode.
141///
142/// When `fix` is `Apply`, repairable violations are written back to each file
143/// before the final counts are computed, so the returned counts reflect only
144/// the violations that *remain* after fixing. With `DryRun`, counts reflect
145/// the post-fix state but files are untouched.
146pub fn lint_files_with_options(
147    files: &[(std::path::PathBuf, String)],
148    schema: &SchemaConfig,
149    fix: FixMode,
150) -> Result<(CommandOutcome, LintCounts)> {
151    let mut results: Vec<FileLintResult> = Vec::new();
152    let mut counts = LintCounts::default();
153    let mut fix_results: Vec<FileFixResult> = Vec::new();
154
155    for (full_path, rel_path) in files {
156        let (file_result, file_fixes) = lint_file_with_fix(full_path, rel_path, schema, fix)?;
157        for v in &file_result.violations {
158            match v.severity {
159                Severity::Error => counts.errors += 1,
160                Severity::Warn => counts.warnings += 1,
161            }
162        }
163        if !file_result.violations.is_empty() {
164            counts.files_with_issues += 1;
165        }
166        if !file_fixes.actions.is_empty() {
167            fix_results.push(file_fixes);
168        }
169        results.push(file_result);
170    }
171
172    let files_checked = files.len();
173    let total = counts.errors + counts.warnings;
174    let output = LintOutput {
175        files: results,
176        total,
177        files_checked,
178        fixes: fix_results,
179        dry_run: matches!(fix, FixMode::DryRun),
180    };
181
182    let val = serde_json::to_value(&output).context("failed to serialize lint output")?;
183    let outcome = CommandOutcome::success(format_success(Format::Json, &val));
184
185    Ok((outcome, counts))
186}
187
188/// Compute lint counts for `hyalo summary` without formatting output.
189pub fn lint_counts_only(
190    files: &[(std::path::PathBuf, String)],
191    schema: &SchemaConfig,
192) -> Result<LintCounts> {
193    let mut counts = LintCounts::default();
194    for (full_path, rel_path) in files {
195        let file_result = lint_file(full_path, rel_path, schema)?;
196        for v in &file_result.violations {
197            match v.severity {
198                Severity::Error => counts.errors += 1,
199                Severity::Warn => counts.warnings += 1,
200            }
201        }
202        if !file_result.violations.is_empty() {
203            counts.files_with_issues += 1;
204        }
205    }
206    Ok(counts)
207}
208
209/// Compute lint counts from pre-indexed `IndexEntry` properties.
210///
211/// Used by `hyalo summary` to avoid re-reading files from disk.
212/// The `index_entries` iterator yields `(rel_path, properties, has_tags)` tuples.
213pub fn lint_counts_from_properties<'a>(
214    entries: impl Iterator<Item = (&'a str, &'a IndexMap<String, Value>, bool)>,
215    schema: &SchemaConfig,
216) -> LintCounts {
217    let mut counts = LintCounts::default();
218    for (rel_path, properties, has_tags) in entries {
219        let violations = validate_properties(rel_path, properties, has_tags, schema);
220        for v in &violations {
221            match v.severity {
222                Severity::Error => counts.errors += 1,
223                Severity::Warn => counts.warnings += 1,
224            }
225        }
226        if !violations.is_empty() {
227            counts.files_with_issues += 1;
228        }
229    }
230    counts
231}
232
233// ---------------------------------------------------------------------------
234// Per-file validation
235// ---------------------------------------------------------------------------
236
237fn lint_file(full_path: &Path, rel_path: &str, schema: &SchemaConfig) -> Result<FileLintResult> {
238    let (result, _) = lint_file_with_fix(full_path, rel_path, schema, FixMode::Off)?;
239    Ok(result)
240}
241
242/// Lint a single file, optionally applying auto-fixes.
243fn lint_file_with_fix(
244    full_path: &Path,
245    rel_path: &str,
246    schema: &SchemaConfig,
247    fix: FixMode,
248) -> Result<(FileLintResult, FileFixResult)> {
249    let properties = match read_frontmatter(full_path) {
250        Ok(props) => props,
251        Err(e) if hyalo_core::frontmatter::is_parse_error(&e) => {
252            // Malformed frontmatter — report as a single error violation.
253            return Ok((
254                FileLintResult {
255                    file: rel_path.to_owned(),
256                    violations: vec![Violation {
257                        severity: Severity::Error,
258                        message: format!("could not parse frontmatter: {e}"),
259                    }],
260                },
261                FileFixResult {
262                    file: rel_path.to_owned(),
263                    actions: Vec::new(),
264                },
265            ));
266        }
267        Err(e) => return Err(e).context(format!("reading {rel_path}")),
268    };
269
270    // Apply fixes in memory (or dry-run) before final validation.
271    let (final_props, actions) = if matches!(fix, FixMode::Apply | FixMode::DryRun) {
272        let mut mutable = properties.clone();
273        let actions = apply_fixes(rel_path, &mut mutable, schema);
274        if matches!(fix, FixMode::Apply) && !actions.is_empty() {
275            write_frontmatter(full_path, &mutable)
276                .with_context(|| format!("writing fixed frontmatter to {rel_path}"))?;
277        }
278        (mutable, actions)
279    } else {
280        (properties, Vec::new())
281    };
282
283    let has_tags = final_props.contains_key("tags");
284    let violations = validate_properties(rel_path, &final_props, has_tags, schema);
285    Ok((
286        FileLintResult {
287            file: rel_path.to_owned(),
288            violations,
289        },
290        FileFixResult {
291            file: rel_path.to_owned(),
292            actions,
293        },
294    ))
295}
296
297// ---------------------------------------------------------------------------
298// Auto-fix
299// ---------------------------------------------------------------------------
300
301/// Maximum Levenshtein distance accepted for an enum-typo fix.
302/// Chosen so that single-letter slips (e.g. "planed" → "planned") are corrected
303/// while unrelated values (e.g. "wip" vs. "in-progress") are left alone.
304const ENUM_TYPO_MAX_DISTANCE: usize = 2;
305
306/// Compute and apply in-memory auto-fixes to `props`. Returns the list of
307/// actions that were taken. Caller is responsible for persisting `props` to
308/// disk when appropriate.
309fn apply_fixes(
310    rel_path: &str,
311    props: &mut IndexMap<String, Value>,
312    schema: &SchemaConfig,
313) -> Vec<FixAction> {
314    let mut actions: Vec<FixAction> = Vec::new();
315
316    // Step 1: infer `type` from filename-template if missing.
317    if !props.contains_key("type")
318        && let Some(inferred) = infer_type_from_path(rel_path, schema)
319    {
320        // Insert `type` at the front of the map so downstream logic picks it up.
321        props.shift_insert(0, "type".to_owned(), Value::String(inferred.clone()));
322        actions.push(FixAction {
323            kind: "infer-type".to_owned(),
324            property: "type".to_owned(),
325            old: None,
326            new: inferred,
327        });
328    }
329
330    // Determine the effective schema after any type inference.
331    let doc_type: Option<String> = props.get("type").and_then(|v| match v {
332        Value::String(s) => Some(s.clone()),
333        _ => None,
334    });
335    let effective_schema: TypeSchema = match &doc_type {
336        Some(t) => schema.merged_schema_for_type(t),
337        None => schema.default_schema().clone(),
338    };
339
340    // Step 2: insert defaults for missing properties.
341    // Iterate in the schema's `required` order first, then any remaining defaults,
342    // so the resulting frontmatter is ordered deterministically.
343    let mut inserted: std::collections::HashSet<String> = std::collections::HashSet::new();
344    for req in &effective_schema.required {
345        if !props.contains_key(req.as_str())
346            && let Some(raw) = effective_schema.defaults.get(req.as_str())
347        {
348            let value = schema::expand_default(raw);
349            props.insert(req.clone(), Value::String(value.clone()));
350            inserted.insert(req.clone());
351            actions.push(FixAction {
352                kind: "insert-default".to_owned(),
353                property: req.clone(),
354                old: None,
355                new: value,
356            });
357        }
358    }
359    // Also honour defaults for properties not listed in `required`.
360    for (name, raw) in &effective_schema.defaults {
361        if inserted.contains(name) || props.contains_key(name.as_str()) {
362            continue;
363        }
364        let value = schema::expand_default(raw);
365        props.insert(name.clone(), Value::String(value.clone()));
366        actions.push(FixAction {
367            kind: "insert-default".to_owned(),
368            property: name.clone(),
369            old: None,
370            new: value,
371        });
372    }
373
374    // Step 3: per-property fixes (enum typos, date normalization).
375    let prop_names: Vec<String> = props.keys().cloned().collect();
376    for name in prop_names {
377        let Some(constraint) = effective_schema.properties.get(name.as_str()) else {
378            continue;
379        };
380        // Snapshot the current value to avoid double-borrowing `props`.
381        let Some(current) = props.get(name.as_str()).cloned() else {
382            continue;
383        };
384        match constraint {
385            PropertyConstraint::Enum { values } => {
386                let Value::String(s) = &current else { continue };
387                if values.iter().any(|v| v == s) {
388                    continue;
389                }
390                if let Some((suggestion, dist)) = values
391                    .iter()
392                    .map(|v| (v, strsim::levenshtein(s, v.as_str())))
393                    .min_by_key(|(_, d)| *d)
394                    && dist <= ENUM_TYPO_MAX_DISTANCE
395                {
396                    let old = s.clone();
397                    let new_value = suggestion.clone();
398                    props.insert(name.clone(), Value::String(new_value.clone()));
399                    actions.push(FixAction {
400                        kind: "fix-enum-typo".to_owned(),
401                        property: name.clone(),
402                        old: Some(old),
403                        new: new_value,
404                    });
405                }
406            }
407            PropertyConstraint::Date => {
408                let Value::String(s) = &current else { continue };
409                if is_iso8601_date(s) {
410                    continue;
411                }
412                if let Some(normalized) = normalize_date(s) {
413                    let old = s.clone();
414                    props.insert(name.clone(), Value::String(normalized.clone()));
415                    actions.push(FixAction {
416                        kind: "normalize-date".to_owned(),
417                        property: name.clone(),
418                        old: Some(old),
419                        new: normalized,
420                    });
421                }
422            }
423            _ => {}
424        }
425    }
426
427    actions
428}
429
430/// Try to infer a `type` value for a file at `rel_path` by matching it against
431/// every `[schema.types.*].filename-template`. Returns `None` if zero or more
432/// than one type matches (ambiguous).
433fn infer_type_from_path(rel_path: &str, schema: &SchemaConfig) -> Option<String> {
434    let mut matches: Vec<String> = Vec::new();
435    for (type_name, ts) in &schema.types {
436        let Some(template_str) = &ts.filename_template else {
437            continue;
438        };
439        let Ok(template) = FilenameTemplate::parse(template_str) else {
440            continue;
441        };
442        if template.matches(rel_path) {
443            matches.push(type_name.clone());
444        }
445    }
446    if matches.len() == 1 {
447        matches.pop()
448    } else {
449        None
450    }
451}
452
453/// Normalize a loose date string to `YYYY-MM-DD`.
454///
455/// Accepts inputs of the form `Y-M-D` where `Y`, `M`, `D` are decimal digit
456/// runs and month/day are in the valid calendar ranges. Returns `None` for
457/// inputs that are ambiguous (e.g. natural-language dates, non-ISO separators,
458/// or out-of-range values); those are reported as violations instead.
459fn normalize_date(s: &str) -> Option<String> {
460    let parts: Vec<&str> = s.split('-').collect();
461    if parts.len() != 3 {
462        return None;
463    }
464    let y = parts[0];
465    let m = parts[1];
466    let d = parts[2];
467    if y.len() != 4 || !y.bytes().all(|b| b.is_ascii_digit()) {
468        return None;
469    }
470    if m.is_empty() || m.len() > 2 || !m.bytes().all(|b| b.is_ascii_digit()) {
471        return None;
472    }
473    if d.is_empty() || d.len() > 2 || !d.bytes().all(|b| b.is_ascii_digit()) {
474        return None;
475    }
476    let yi: i32 = y.parse().ok()?;
477    let mi: u32 = m.parse().ok()?;
478    let di: u32 = d.parse().ok()?;
479    if !(1..=12).contains(&mi) {
480        return None;
481    }
482    let max_day = match mi {
483        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
484        4 | 6 | 9 | 11 => 30,
485        2 => {
486            let leap = (yi % 4 == 0 && yi % 100 != 0) || (yi % 400 == 0);
487            if leap { 29 } else { 28 }
488        }
489        _ => return None,
490    };
491    if !(1..=max_day).contains(&di) {
492        return None;
493    }
494    Some(format!("{y}-{mi:02}-{di:02}"))
495}
496
497/// Core property validation logic.
498///
499/// Separated so it can be used both by the disk-reading path (`lint_file`) and
500/// the index-based path (`lint_counts_from_properties`).
501fn validate_properties(
502    _rel_path: &str,
503    properties: &IndexMap<String, Value>,
504    has_tags: bool,
505    schema: &SchemaConfig,
506) -> Vec<Violation> {
507    let mut violations: Vec<Violation> = Vec::new();
508
509    // Determine the document type.
510    let type_value = properties.get("type");
511    let doc_type: Option<String> = type_value.and_then(|v| match v {
512        Value::String(s) => Some(s.clone()),
513        _ => None,
514    });
515
516    // If `type` is present but not a string, report an error. A non-string `type`
517    // still satisfies a bare `required = ["type"]` check, so without this error
518    // invalid type values would slip through silently.
519    if let Some(v) = type_value
520        && doc_type.is_none()
521    {
522        violations.push(Violation {
523            severity: Severity::Error,
524            message: format!("property \"type\" expected string, got {v}"),
525        });
526    }
527
528    // Warn when no `type` property is present.
529    if type_value.is_none() && !schema.is_empty() {
530        violations.push(Violation {
531            severity: Severity::Warn,
532            message: "no 'type' property — validating against default schema only".to_owned(),
533        });
534    }
535
536    // Determine the effective schema for this file.
537    let effective_schema: TypeSchema = match &doc_type {
538        Some(t) => schema.merged_schema_for_type(t),
539        None => schema.default_schema().clone(),
540    };
541
542    // Check required properties.
543    for req in &effective_schema.required {
544        if !properties.contains_key(req.as_str()) {
545            let type_hint = doc_type
546                .as_deref()
547                .map(|t| format!(" (type: {t})"))
548                .unwrap_or_default();
549            violations.push(Violation {
550                severity: Severity::Error,
551                message: format!("missing required property \"{req}\"{type_hint}"),
552            });
553        }
554    }
555
556    // Warn when no `tags` property is present and the schema has at least one type defined.
557    if !has_tags && !schema.types.is_empty() {
558        violations.push(Violation {
559            severity: Severity::Warn,
560            message: "no tags defined".to_owned(),
561        });
562    }
563
564    // Build a per-call regex cache so the same pattern isn't recompiled across
565    // properties (this matters in `hyalo summary`, which runs lint over the full
566    // index).
567    let mut regex_cache: HashMap<String, Result<Regex, String>> = HashMap::new();
568
569    // Type-specific property constraint validation.
570    for (name, value) in properties {
571        // `tags` is validated against its declared constraint if present, but we
572        // never emit an "undeclared property" warning for it (it has its own
573        // "no tags defined" warning above).
574        if name == "tags" {
575            if let Some(constraint) = effective_schema.properties.get(name.as_str())
576                && let Some(v) = validate_constraint(name, value, constraint, &mut regex_cache)
577            {
578                violations.push(v);
579            }
580            continue;
581        }
582        // Never warn about "type" (type discriminator) or properties listed in `required`
583        // — they're implicitly accepted even if not in the `properties` map.
584        let implicitly_accepted = name == "type" || effective_schema.required.contains(name);
585
586        if let Some(constraint) = effective_schema.properties.get(name.as_str()) {
587            if let Some(v) = validate_constraint(name, value, constraint, &mut regex_cache) {
588                violations.push(v);
589            }
590        } else if !effective_schema.properties.is_empty() && !implicitly_accepted {
591            // Property not declared in schema — warn only when the schema declares
592            // some properties. Schemas that only specify `required` remain
593            // intentionally permissive about extra fields.
594            violations.push(Violation {
595                severity: Severity::Warn,
596                message: format!("property \"{name}\" is not declared in schema"),
597            });
598        }
599    }
600
601    violations
602}
603
604// ---------------------------------------------------------------------------
605// Constraint validators
606// ---------------------------------------------------------------------------
607
608fn validate_constraint(
609    name: &str,
610    value: &Value,
611    constraint: &PropertyConstraint,
612    regex_cache: &mut HashMap<String, Result<Regex, String>>,
613) -> Option<Violation> {
614    match constraint {
615        PropertyConstraint::String { pattern } => {
616            let Some(s) = value_as_str(value) else {
617                return Some(Violation {
618                    severity: Severity::Error,
619                    message: format!("property \"{name}\" expected string, got {value}"),
620                });
621            };
622            if let Some(pat) = pattern {
623                // Compile (or look up) the regex once per pattern per call.
624                let entry = regex_cache
625                    .entry(pat.clone())
626                    .or_insert_with(|| Regex::new(pat).map_err(|e| e.to_string()));
627                match entry {
628                    Ok(re) => {
629                        if !re.is_match(s) {
630                            return Some(Violation {
631                                severity: Severity::Error,
632                                message: format!(
633                                    "property \"{name}\" value {s:?} does not match pattern {pat:?}"
634                                ),
635                            });
636                        }
637                    }
638                    Err(e) => {
639                        return Some(Violation {
640                            severity: Severity::Error,
641                            message: format!("property \"{name}\": invalid pattern {pat:?}: {e}"),
642                        });
643                    }
644                }
645            }
646            None
647        }
648        PropertyConstraint::Date => {
649            let Some(s) = value_as_str(value) else {
650                return Some(Violation {
651                    severity: Severity::Error,
652                    message: format!("property \"{name}\" expected date (YYYY-MM-DD), got {value}"),
653                });
654            };
655            if !is_iso8601_date(s) {
656                return Some(Violation {
657                    severity: Severity::Error,
658                    message: format!("property \"{name}\" expected date (YYYY-MM-DD), got \"{s}\""),
659                });
660            }
661            None
662        }
663        PropertyConstraint::Number => {
664            if !matches!(value, Value::Number(_)) {
665                return Some(Violation {
666                    severity: Severity::Error,
667                    message: format!("property \"{name}\" expected number, got {value}"),
668                });
669            }
670            None
671        }
672        PropertyConstraint::Boolean => {
673            if !matches!(value, Value::Bool(_)) {
674                return Some(Violation {
675                    severity: Severity::Error,
676                    message: format!("property \"{name}\" expected boolean, got {value}"),
677                });
678            }
679            None
680        }
681        PropertyConstraint::List => {
682            if !matches!(value, Value::Array(_)) {
683                return Some(Violation {
684                    severity: Severity::Error,
685                    message: format!("property \"{name}\" expected list, got {value}"),
686                });
687            }
688            None
689        }
690        PropertyConstraint::Enum { values } => {
691            let Some(s) = value_as_str(value) else {
692                return Some(Violation {
693                    severity: Severity::Error,
694                    message: format!(
695                        "property \"{name}\" expected one of [{}], got {value}",
696                        values.join(", ")
697                    ),
698                });
699            };
700            if values.contains(&s.to_owned()) {
701                return None;
702            }
703            // Find nearest suggestion via Levenshtein.
704            let suggestion = values
705                .iter()
706                .min_by_key(|v| strsim::levenshtein(s, v.as_str()))
707                .map(|v| format!(" (did you mean \"{v}\"?)"))
708                .unwrap_or_default();
709            Some(Violation {
710                severity: Severity::Error,
711                message: format!(
712                    "property \"{name}\" value \"{s}\" not in [{}]{suggestion}",
713                    values.join(", ")
714                ),
715            })
716        }
717    }
718}
719
720/// Extract a `&str` from a `Value::String`, or `None` for other variants.
721fn value_as_str(v: &Value) -> Option<&str> {
722    if let Value::String(s) = v {
723        Some(s.as_str())
724    } else {
725        None
726    }
727}
728
729// ---------------------------------------------------------------------------
730// Text formatter
731// ---------------------------------------------------------------------------
732
733// ---------------------------------------------------------------------------
734// Unit tests
735// ---------------------------------------------------------------------------
736
737#[cfg(test)]
738mod tests {
739    use super::*;
740    use hyalo_core::schema::{PropertyConstraint, SchemaConfig, TypeSchema};
741    use std::collections::HashMap;
742
743    fn make_schema(
744        default_required: &[&str],
745        type_name: &str,
746        type_required: &[&str],
747        type_properties: HashMap<&str, PropertyConstraint>,
748    ) -> SchemaConfig {
749        let default = TypeSchema {
750            required: default_required.iter().map(ToString::to_string).collect(),
751            ..Default::default()
752        };
753        let mut props: HashMap<String, PropertyConstraint> = HashMap::new();
754        for (k, v) in type_properties {
755            props.insert(k.to_owned(), v);
756        }
757        let type_schema = TypeSchema {
758            required: type_required.iter().map(ToString::to_string).collect(),
759            properties: props,
760            ..Default::default()
761        };
762        let mut types = HashMap::new();
763        types.insert(type_name.to_owned(), type_schema);
764        SchemaConfig { default, types }
765    }
766
767    // --- is_iso8601_date ---
768
769    #[test]
770    fn valid_date() {
771        assert!(is_iso8601_date("2026-04-13"));
772    }
773
774    #[test]
775    fn normalize_date_padding_and_calendar() {
776        // Short month/day get zero-padded.
777        assert_eq!(normalize_date("2026-4-9"), Some("2026-04-09".to_owned()));
778        // Feb 29 is valid in leap years only.
779        assert_eq!(normalize_date("2024-2-29"), Some("2024-02-29".to_owned()));
780        assert_eq!(normalize_date("2023-2-29"), None);
781        // Out-of-range days/months are rejected, not silently normalized.
782        assert_eq!(normalize_date("2026-02-31"), None);
783        assert_eq!(normalize_date("2026-04-31"), None);
784        assert_eq!(normalize_date("2026-13-01"), None);
785    }
786
787    #[test]
788    fn invalid_date_format() {
789        assert!(!is_iso8601_date("April 13"));
790        assert!(!is_iso8601_date("13-04-2026"));
791        assert!(!is_iso8601_date("2026/04/13"));
792    }
793
794    // Test helper: wraps `validate_constraint` with a throwaway regex cache.
795    fn vc(name: &str, value: &Value, c: &PropertyConstraint) -> Option<Violation> {
796        let mut cache = HashMap::new();
797        validate_constraint(name, value, c, &mut cache)
798    }
799
800    // --- validate_constraint ---
801
802    #[test]
803    fn date_constraint_valid() {
804        let v = vc(
805            "date",
806            &Value::String("2026-04-13".into()),
807            &PropertyConstraint::Date,
808        );
809        assert!(v.is_none());
810    }
811
812    #[test]
813    fn date_constraint_invalid() {
814        let v = vc(
815            "date",
816            &Value::String("April 13".into()),
817            &PropertyConstraint::Date,
818        );
819        assert!(matches!(
820            v,
821            Some(Violation {
822                severity: Severity::Error,
823                ..
824            })
825        ));
826    }
827
828    #[test]
829    fn enum_constraint_valid() {
830        let v = vc(
831            "status",
832            &Value::String("planned".into()),
833            &PropertyConstraint::Enum {
834                values: vec!["planned".into(), "done".into()],
835            },
836        );
837        assert!(v.is_none());
838    }
839
840    #[test]
841    fn enum_constraint_invalid_with_suggestion() {
842        let v = vc(
843            "status",
844            &Value::String("planed".into()),
845            &PropertyConstraint::Enum {
846                values: vec!["planned".into(), "done".into()],
847            },
848        );
849        let viol = v.expect("expected violation");
850        assert_eq!(viol.severity, Severity::Error);
851        assert!(viol.message.contains("did you mean \"planned\""));
852    }
853
854    #[test]
855    fn number_constraint_valid() {
856        let v = vc(
857            "priority",
858            &Value::Number(5.into()),
859            &PropertyConstraint::Number,
860        );
861        assert!(v.is_none());
862    }
863
864    #[test]
865    fn number_constraint_invalid() {
866        let v = vc(
867            "priority",
868            &Value::String("five".into()),
869            &PropertyConstraint::Number,
870        );
871        assert!(matches!(
872            v,
873            Some(Violation {
874                severity: Severity::Error,
875                ..
876            })
877        ));
878    }
879
880    #[test]
881    fn boolean_constraint_valid() {
882        let v = vc("draft", &Value::Bool(true), &PropertyConstraint::Boolean);
883        assert!(v.is_none());
884    }
885
886    #[test]
887    fn boolean_constraint_invalid() {
888        let v = vc(
889            "draft",
890            &Value::String("yes".into()),
891            &PropertyConstraint::Boolean,
892        );
893        assert!(matches!(
894            v,
895            Some(Violation {
896                severity: Severity::Error,
897                ..
898            })
899        ));
900    }
901
902    #[test]
903    fn list_constraint_valid() {
904        let v = vc("tags", &Value::Array(vec![]), &PropertyConstraint::List);
905        assert!(v.is_none());
906    }
907
908    #[test]
909    fn list_constraint_invalid() {
910        let v = vc(
911            "tags",
912            &Value::String("rust".into()),
913            &PropertyConstraint::List,
914        );
915        assert!(matches!(
916            v,
917            Some(Violation {
918                severity: Severity::Error,
919                ..
920            })
921        ));
922    }
923
924    #[test]
925    fn string_pattern_constraint_valid() {
926        let v = vc(
927            "branch",
928            &Value::String("iter-42/my-feature".into()),
929            &PropertyConstraint::String {
930                pattern: Some(r"^iter-\d+/".into()),
931            },
932        );
933        assert!(v.is_none());
934    }
935
936    #[test]
937    fn string_pattern_constraint_invalid() {
938        let v = vc(
939            "branch",
940            &Value::String("feature/my-branch".into()),
941            &PropertyConstraint::String {
942                pattern: Some(r"^iter-\d+/".into()),
943            },
944        );
945        assert!(matches!(
946            v,
947            Some(Violation {
948                severity: Severity::Error,
949                ..
950            })
951        ));
952    }
953
954    // --- lint_file via a temp file ---
955
956    #[test]
957    fn lint_file_missing_required() {
958        let dir = tempfile::tempdir().unwrap();
959        let path = dir.path().join("note.md");
960        std::fs::write(&path, "---\ntitle: Hello\n---\nBody\n").unwrap();
961
962        let schema = make_schema(&["title", "date"], "note", &[], HashMap::new());
963        let result = lint_file(&path, "note.md", &schema).unwrap();
964        // date is in default required, but only "title" is present.
965        // No type -> warn about no type. date missing -> error.
966        assert!(
967            result
968                .violations
969                .iter()
970                .any(|v| v.severity == Severity::Error
971                    && v.message.contains("missing required property \"date\""))
972        );
973    }
974
975    #[test]
976    fn lint_file_no_type_warn() {
977        let dir = tempfile::tempdir().unwrap();
978        let path = dir.path().join("note.md");
979        std::fs::write(&path, "---\ntitle: Hello\n---\nBody\n").unwrap();
980
981        let schema = make_schema(&["title"], "note", &[], HashMap::new());
982        let result = lint_file(&path, "note.md", &schema).unwrap();
983        assert!(
984            result
985                .violations
986                .iter()
987                .any(|v| v.severity == Severity::Warn && v.message.contains("no 'type' property"))
988        );
989    }
990
991    #[test]
992    fn lint_file_no_violations_clean_file() {
993        let dir = tempfile::tempdir().unwrap();
994        let path = dir.path().join("note.md");
995        std::fs::write(
996            &path,
997            "---\ntitle: Hello\ntype: note\ntags:\n  - rust\n---\nBody\n",
998        )
999        .unwrap();
1000
1001        let schema = make_schema(&["title"], "note", &[], HashMap::new());
1002        let result = lint_file(&path, "note.md", &schema).unwrap();
1003        assert!(result.violations.is_empty());
1004    }
1005
1006    #[test]
1007    fn lint_no_schema_no_violations() {
1008        let dir = tempfile::tempdir().unwrap();
1009        let path = dir.path().join("note.md");
1010        std::fs::write(&path, "---\ntitle: Hello\n---\nBody\n").unwrap();
1011
1012        let schema = SchemaConfig::default();
1013        let files = vec![(path, "note.md".to_owned())];
1014        let (_, counts) = lint_files(&files, &schema).unwrap();
1015        assert_eq!(counts.errors, 0);
1016        assert_eq!(counts.warnings, 0);
1017    }
1018}