Skip to main content

sqry_core/config/
graph_config_schema.rs

1//! Graph config schema - unified config partition types and validation.
2//!
3//! Implements Step 2 of the Unified Graph Config Partition feature:
4//! - Type definitions for `.sqry/graph/config/config.json`
5//! - Default values for all configuration sections
6//! - Validation logic for schema and values
7//!
8//! # Schema Version
9//!
10//! The schema version is incremented when breaking changes are made to the
11//! config structure. Non-breaking additions (new optional fields) do not
12//! require a version bump.
13//!
14//! # Design
15//!
16//! See: `docs/development/unified-graph-config-partition/02_DESIGN.md`
17
18use serde::{Deserialize, Serialize};
19use std::collections::BTreeMap;
20use thiserror::Error;
21
22use super::project_config::{
23    CacheConfig, IgnoreConfig, IncludeConfig, IndexingConfig, LanguageConfig,
24};
25
26/// Current schema version for config files
27pub const SCHEMA_VERSION: u32 = 1;
28
29/// Errors that can occur during schema validation
30#[derive(Debug, Error)]
31pub enum SchemaValidationError {
32    /// Schema version is incompatible
33    #[error("Incompatible schema version: expected {expected}, found {found}")]
34    IncompatibleVersion {
35        /// Expected schema version
36        expected: u32,
37        /// Actual schema version found
38        found: u32,
39    },
40
41    /// A required field is missing
42    #[error("Missing required field: {0}")]
43    MissingField(String),
44
45    /// A field has an invalid type
46    #[error("Invalid type for field '{field}': expected {expected}, got {got}")]
47    InvalidType {
48        /// Name of the field with invalid type
49        field: String,
50        /// Expected type name
51        expected: String,
52        /// Actual type name found
53        got: String,
54    },
55
56    /// A field has an invalid value
57    #[error("Invalid value for field '{field}': {reason}")]
58    InvalidValue {
59        /// Name of the field with invalid value
60        field: String,
61        /// Reason why the value is invalid
62        reason: String,
63    },
64}
65
66/// Result type for schema validation
67pub type ValidationResult<T> = Result<T, SchemaValidationError>;
68
69// ============================================================================
70// Top-level structure
71// ============================================================================
72
73/// Top-level config file structure
74///
75/// This is the complete structure stored in `.sqry/graph/config/config.json`.
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
77pub struct GraphConfigFile {
78    /// Schema version for compatibility checking
79    pub schema_version: u32,
80
81    /// Metadata about the config file itself
82    pub metadata: GraphConfigMetadata,
83
84    /// Integrity information for corruption detection
85    pub integrity: GraphConfigIntegrity,
86
87    /// The actual configuration settings
88    pub config: GraphConfig,
89
90    /// Custom extensions for future use
91    #[serde(default)]
92    pub extensions: GraphConfigExtensions,
93}
94
95impl Default for GraphConfigFile {
96    fn default() -> Self {
97        Self {
98            schema_version: SCHEMA_VERSION,
99            metadata: GraphConfigMetadata::default(),
100            integrity: GraphConfigIntegrity::default(),
101            config: GraphConfig::default(),
102            extensions: GraphConfigExtensions::default(),
103        }
104    }
105}
106
107impl GraphConfigFile {
108    /// Create a new config file with default settings
109    #[must_use]
110    pub fn new() -> Self {
111        Self::default()
112    }
113
114    /// Validate the schema version is compatible
115    ///
116    /// # Errors
117    ///
118    /// Returns an error if the schema version is incompatible.
119    pub fn validate_version(&self) -> ValidationResult<()> {
120        if self.schema_version != SCHEMA_VERSION {
121            return Err(SchemaValidationError::IncompatibleVersion {
122                expected: SCHEMA_VERSION,
123                found: self.schema_version,
124            });
125        }
126        Ok(())
127    }
128
129    /// Validate the entire config structure
130    ///
131    /// # Errors
132    ///
133    /// Returns an error if any nested config section fails validation.
134    pub fn validate(&self) -> ValidationResult<()> {
135        self.validate_version()?;
136        self.config.validate()?;
137        Ok(())
138    }
139}
140
141// ============================================================================
142// Metadata section
143// ============================================================================
144
145/// Metadata about the config file
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
147pub struct GraphConfigMetadata {
148    /// When the config file was first created
149    pub created_at: String,
150
151    /// When the config file was last updated
152    pub updated_at: String,
153
154    /// Information about the tool that wrote this config
155    pub written_by: WrittenByInfo,
156}
157
158impl Default for GraphConfigMetadata {
159    fn default() -> Self {
160        let now = chrono::Utc::now().to_rfc3339();
161        Self {
162            created_at: now.clone(),
163            updated_at: now,
164            written_by: WrittenByInfo::default(),
165        }
166    }
167}
168
169/// Information about the tool that wrote the config
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct WrittenByInfo {
172    /// sqry version that wrote this config
173    pub sqry_version: String,
174
175    /// Rust compiler version
176    #[serde(skip_serializing_if = "Option::is_none")]
177    pub rustc_version: Option<String>,
178
179    /// Git revision (if available)
180    #[serde(skip_serializing_if = "Option::is_none")]
181    pub git_revision: Option<String>,
182}
183
184impl Default for WrittenByInfo {
185    fn default() -> Self {
186        Self {
187            sqry_version: env!("CARGO_PKG_VERSION").to_string(),
188            rustc_version: None,
189            git_revision: None,
190        }
191    }
192}
193
194// ============================================================================
195// Integrity section
196// ============================================================================
197
198/// Integrity information for corruption detection
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
200pub struct GraphConfigIntegrity {
201    /// Hash algorithm used
202    pub normalized_hash_alg: String,
203
204    /// What was hashed (always "config" for now)
205    pub normalized_hash_of: String,
206
207    /// The computed hash (hex-encoded)
208    pub normalized_hash: String,
209
210    /// When the hash was last verified
211    pub last_verified_at: String,
212}
213
214impl Default for GraphConfigIntegrity {
215    fn default() -> Self {
216        Self {
217            normalized_hash_alg: "blake3".to_string(),
218            normalized_hash_of: "config".to_string(),
219            normalized_hash: String::new(), // Computed on save
220            last_verified_at: chrono::Utc::now().to_rfc3339(),
221        }
222    }
223}
224
225// ============================================================================
226// Main config section
227// ============================================================================
228
229/// Main configuration settings
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
231#[serde(default)]
232#[derive(Default)]
233pub struct GraphConfig {
234    /// User-defined query aliases
235    pub aliases: BTreeMap<String, AliasEntry>,
236
237    /// CLI preferences
238    pub cli: CliPreferences,
239
240    /// Validation behavior
241    pub validation: ValidationConfig,
242
243    /// Locking behavior
244    pub locking: LockingConfig,
245
246    /// Durability settings
247    pub durability: DurabilityConfig,
248
249    /// Operational limits (configurable, not hard-coded)
250    pub limits: LimitsConfig,
251
252    /// Output formatting settings
253    pub output: OutputConfig,
254
255    /// Parallelism settings
256    pub parallelism: ParallelismConfig,
257
258    /// Timeout settings
259    pub timeouts: TimeoutsConfig,
260
261    /// Graph persistence settings
262    pub persistence: PersistenceConfig,
263
264    /// Indexing configuration (from `ProjectConfig`)
265    #[serde(default)]
266    pub indexing: IndexingConfig,
267
268    /// Cache configuration (from `ProjectConfig`)
269    #[serde(default)]
270    pub cache: CacheConfig,
271
272    /// Language configuration (from `ProjectConfig`)
273    #[serde(default)]
274    pub languages: LanguageConfig,
275
276    /// Include patterns (from `ProjectConfig`)
277    #[serde(default)]
278    pub include: IncludeConfig,
279
280    /// Ignore patterns (from `ProjectConfig`)
281    #[serde(default)]
282    pub ignore: IgnoreConfig,
283
284    /// Buffer size configurations
285    #[serde(default)]
286    pub buffers: BuffersConfig,
287}
288
289impl GraphConfig {
290    /// Validate all config settings
291    ///
292    /// # Errors
293    ///
294    /// Returns an error if any section fails validation.
295    pub fn validate(&self) -> ValidationResult<()> {
296        self.limits.validate()?;
297        self.locking.validate()?;
298        self.output.validate()?;
299        self.timeouts.validate()?;
300        self.persistence.validate()?;
301        Ok(())
302    }
303}
304
305// ============================================================================
306// Alias entry
307// ============================================================================
308
309/// A user-defined query alias
310#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
311pub struct AliasEntry {
312    /// The query this alias expands to
313    pub query: String,
314
315    /// Optional description of what the alias does
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub description: Option<String>,
318
319    /// When the alias was created
320    pub created_at: String,
321
322    /// When the alias was last updated
323    pub updated_at: String,
324}
325
326impl AliasEntry {
327    /// Create a new alias entry
328    pub fn new(query: impl Into<String>, description: Option<String>) -> Self {
329        let now = chrono::Utc::now().to_rfc3339();
330        Self {
331            query: query.into(),
332            description,
333            created_at: now.clone(),
334            updated_at: now,
335        }
336    }
337}
338
339// ============================================================================
340// CLI preferences
341// ============================================================================
342
343/// CLI behavior preferences
344#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
345#[serde(default)]
346pub struct CliPreferences {
347    /// Default output format (pretty, json, raw)
348    pub default_output_format: String,
349
350    /// Whether to use JSON output by default
351    pub default_json: bool,
352}
353
354impl Default for CliPreferences {
355    fn default() -> Self {
356        Self {
357            default_output_format: "pretty".to_string(),
358            default_json: false,
359        }
360    }
361}
362
363// ============================================================================
364// Validation config
365// ============================================================================
366
367/// Validation behavior settings
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
369#[serde(default)]
370pub struct ValidationConfig {
371    /// Validation mode: "warn", "strict", or "off"
372    pub mode: String,
373
374    /// Whether to enforce integrity hash matching
375    pub enforce_integrity: bool,
376}
377
378impl Default for ValidationConfig {
379    fn default() -> Self {
380        Self {
381            mode: "warn".to_string(),
382            enforce_integrity: false,
383        }
384    }
385}
386
387// ============================================================================
388// Locking config
389// ============================================================================
390
391/// Writer lock behavior settings
392#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
393#[serde(default)]
394pub struct LockingConfig {
395    /// Timeout for acquiring write lock (milliseconds)
396    pub write_lock_timeout_ms: u64,
397
398    /// Timeout before a lock is considered stale (milliseconds)
399    pub stale_lock_timeout_ms: u64,
400
401    /// Policy for stale locks: "deny", "warn", or "allow"
402    pub stale_takeover_policy: String,
403}
404
405impl Default for LockingConfig {
406    fn default() -> Self {
407        Self {
408            write_lock_timeout_ms: 5000,
409            stale_lock_timeout_ms: 30000,
410            stale_takeover_policy: "allow".to_string(),
411        }
412    }
413}
414
415impl LockingConfig {
416    /// Validate locking config values
417    ///
418    /// # Errors
419    ///
420    /// Returns an error if the takeover policy is invalid.
421    pub fn validate(&self) -> ValidationResult<()> {
422        let valid_policies = ["deny", "warn", "allow"];
423        if !valid_policies.contains(&self.stale_takeover_policy.as_str()) {
424            return Err(SchemaValidationError::InvalidValue {
425                field: "locking.stale_takeover_policy".to_string(),
426                reason: format!(
427                    "must be one of {:?}, got '{}'",
428                    valid_policies, self.stale_takeover_policy
429                ),
430            });
431        }
432        Ok(())
433    }
434}
435
436// ============================================================================
437// Durability config
438// ============================================================================
439
440/// Durability and filesystem settings
441#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
442#[serde(default)]
443#[derive(Default)]
444pub struct DurabilityConfig {
445    /// Allow operation on network filesystems (not recommended)
446    pub allow_network_filesystems: bool,
447}
448
449// ============================================================================
450// Limits config (no hard limits - all configurable)
451// ============================================================================
452
453/// Operational limits - all configurable, no hard-coded caps
454#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
455#[serde(default)]
456pub struct LimitsConfig {
457    /// Maximum number of results to return (0 = unlimited)
458    pub max_results: u64,
459
460    /// Maximum traversal depth for graph queries (0 = unlimited)
461    pub max_depth: u64,
462
463    /// Maximum bytes per file to process (0 = unlimited)
464    pub max_bytes_per_file: u64,
465
466    /// Maximum number of files to index (0 = unlimited)
467    pub max_files: u64,
468
469    /// Maximum repositories/workspaces (0 = unlimited)
470    pub max_repositories: u64,
471
472    /// Maximum query string length (0 = unlimited)
473    pub max_query_length: u64,
474
475    // === Node & Relation Limits ===
476    /// Maximum sample locations to track per cross-language relation (0 = unlimited)
477    pub max_sample_locations: u64,
478
479    /// Maximum cross-language relations per language pair (0 = unlimited)
480    pub max_relations_per_language_pair: u64,
481
482    // === Query Limits ===
483    /// Maximum regex pattern length in queries (0 = unlimited)
484    pub max_regex_length: u64,
485
486    /// Maximum repetition count in query patterns (0 = unlimited)
487    pub max_repetition_count: u64,
488
489    /// Maximum predicates per query (0 = unlimited)
490    pub max_predicates: u64,
491
492    /// Maximum query memory usage in bytes (0 = unlimited)
493    pub max_query_memory_bytes: u64,
494
495    /// Maximum query cost units (0 = unlimited)
496    pub max_query_cost: u64,
497
498    // === Git & External Tools ===
499    /// Maximum git command output size in bytes (0 = unlimited)
500    pub max_git_output_bytes: u64,
501
502    // === Index & Storage Limits ===
503    /// Maximum uncompressed index size in bytes (0 = unlimited)
504    pub max_index_uncompressed_bytes: u64,
505
506    /// Maximum compression ratio for index files (0 = unlimited)
507    pub max_compression_ratio: u64,
508
509    /// Maximum index size in bytes (compressed) (0 = unlimited)
510    pub max_index_bytes: u64,
511
512    // === Prewarm Storage Limits ===
513    /// Maximum prewarm header size in bytes (0 = unlimited)
514    pub max_prewarm_header_bytes: u64,
515
516    /// Maximum prewarm payload size in bytes (0 = unlimited)
517    pub max_prewarm_payload_bytes: u64,
518
519    // === Analysis Limits ===
520    /// Maximum 2-hop label intervals per edge kind (0 = unlimited). Default: 15M.
521    pub analysis_label_budget_per_kind: u64,
522
523    /// Density gate threshold: skip 2-hop labels when `cond_edges > threshold * scc_count`.
524    /// 0 = disabled. Default: 64.
525    pub analysis_density_gate_threshold: u64,
526
527    /// Policy when label budget is exceeded: `"degrade"` (BFS fallback) or `"fail"`.
528    /// Default: `"degrade"`.
529    pub analysis_budget_exceeded_policy: String,
530}
531
532impl Default for LimitsConfig {
533    fn default() -> Self {
534        Self {
535            max_results: 5000,
536            max_depth: 6,
537            max_bytes_per_file: 10 * 1024 * 1024, // 10 MB
538            max_files: 0,                         // unlimited
539            max_repositories: 0,                  // unlimited
540            max_query_length: 0,                  // unlimited
541
542            // Node & Relation Limits
543            max_sample_locations: 3,
544            max_relations_per_language_pair: 10_000,
545
546            // Query Limits
547            max_regex_length: 1000,
548            max_repetition_count: 1000,
549            max_predicates: 100,
550            max_query_memory_bytes: 512 * 1024 * 1024, // 512 MB
551            max_query_cost: 1_000_000,
552
553            // Git & External Tools
554            max_git_output_bytes: 10 * 1024 * 1024, // 10 MB
555
556            // Index & Storage Limits
557            max_index_uncompressed_bytes: 500 * 1024 * 1024, // 500 MB
558            max_compression_ratio: 100,
559            max_index_bytes: 1_000_000_000, // 1 GB
560
561            // Prewarm Storage Limits
562            max_prewarm_header_bytes: 4 * 1024,            // 4 KB
563            max_prewarm_payload_bytes: 1024 * 1024 * 1024, // 1 GB
564
565            // Analysis Limits
566            analysis_label_budget_per_kind: 15_000_000,
567            analysis_density_gate_threshold: 64,
568            analysis_budget_exceeded_policy: "degrade".to_string(),
569        }
570    }
571}
572
573impl LimitsConfig {
574    /// Validate limits config - values are always valid (0 = unlimited)
575    ///
576    /// # Errors
577    ///
578    /// Returns an error if limits validation fails.
579    pub fn validate(&self) -> ValidationResult<()> {
580        // All limits are valid - 0 means unlimited
581        Ok(())
582    }
583}
584
585// ============================================================================
586// Output config
587// ============================================================================
588
589/// Output formatting settings
590#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
591#[serde(default)]
592pub struct OutputConfig {
593    /// Enable pagination by default
594    pub default_pagination: bool,
595
596    /// Default page size for paginated output
597    pub page_size: u64,
598
599    /// Maximum bytes to show in previews
600    pub max_preview_bytes: u64,
601}
602
603impl Default for OutputConfig {
604    fn default() -> Self {
605        Self {
606            default_pagination: true,
607            page_size: 50,
608            max_preview_bytes: 64 * 1024, // 64 KB
609        }
610    }
611}
612
613impl OutputConfig {
614    /// Validate output config values
615    ///
616    /// # Errors
617    ///
618    /// Returns an error if output values are invalid.
619    pub fn validate(&self) -> ValidationResult<()> {
620        if self.page_size == 0 {
621            return Err(SchemaValidationError::InvalidValue {
622                field: "output.page_size".to_string(),
623                reason: "must be greater than 0".to_string(),
624            });
625        }
626        Ok(())
627    }
628}
629
630// ============================================================================
631// Parallelism config
632// ============================================================================
633
634/// Parallelism settings
635#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
636#[serde(default)]
637pub struct ParallelismConfig {
638    /// Maximum threads for parallel operations (0 = auto-detect)
639    pub max_threads: u64,
640
641    /// Maximum lexer pool size (0 = auto-detect)
642    pub lexer_pool_max: u64,
643
644    /// Compaction chunk size for interruptible compaction
645    pub compaction_chunk_size: u64,
646}
647
648impl Default for ParallelismConfig {
649    fn default() -> Self {
650        Self {
651            max_threads: 0, // auto-detect
652            lexer_pool_max: 4,
653            compaction_chunk_size: 10_000,
654        }
655    }
656}
657
658// ============================================================================
659// Timeouts config
660// ============================================================================
661
662/// Timeout settings for various operations
663#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
664#[serde(default)]
665pub struct TimeoutsConfig {
666    /// Query timeout in milliseconds (0 = unlimited)
667    pub query_timeout_ms: u64,
668
669    /// Build/index timeout in milliseconds (0 = unlimited)
670    pub build_timeout_ms: u64,
671
672    /// Parse timeout in microseconds for tree-sitter (0 = unlimited)
673    pub parse_timeout_us: u64,
674
675    /// Session idle timeout in milliseconds (0 = unlimited)
676    pub session_timeout_ms: u64,
677
678    /// File watch debounce timeout in milliseconds (0 = unlimited)
679    pub watch_debounce_ms: u64,
680}
681
682impl Default for TimeoutsConfig {
683    fn default() -> Self {
684        Self {
685            query_timeout_ms: 0,         // unlimited
686            build_timeout_ms: 0,         // unlimited
687            parse_timeout_us: 2_000_000, // 2 seconds
688            session_timeout_ms: 120_000, // 2 minutes
689            watch_debounce_ms: 50,       // 50 milliseconds
690        }
691    }
692}
693
694impl TimeoutsConfig {
695    /// Validate timeout values
696    ///
697    /// # Errors
698    ///
699    /// Returns an error if timeout validation fails.
700    pub fn validate(&self) -> ValidationResult<()> {
701        // 0 means unlimited, all values are valid
702        Ok(())
703    }
704}
705
706// ============================================================================
707// Persistence config
708// ============================================================================
709
710/// Graph persistence size limits
711#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
712#[serde(default)]
713pub struct PersistenceConfig {
714    /// Maximum manifest file size in bytes (0 = unlimited)
715    pub max_manifest_bytes: u64,
716
717    /// Maximum snapshot file size in bytes (0 = unlimited)
718    pub max_snapshot_bytes: u64,
719}
720
721impl Default for PersistenceConfig {
722    fn default() -> Self {
723        Self {
724            max_manifest_bytes: 1024 * 1024, // 1 MB
725            max_snapshot_bytes: 0,           // unlimited
726        }
727    }
728}
729
730impl PersistenceConfig {
731    /// Validate persistence config values
732    ///
733    /// # Errors
734    ///
735    /// Returns an error if persistence validation fails.
736    pub fn validate(&self) -> ValidationResult<()> {
737        // 0 means unlimited, all values are valid
738        Ok(())
739    }
740}
741
742// ============================================================================
743// Buffers config
744// ============================================================================
745
746/// Buffer size configurations
747///
748/// These were previously environment variables or hard-coded defaults.
749/// Now they're fully configurable.
750#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
751#[serde(default)]
752pub struct BuffersConfig {
753    /// Parse buffer size in bytes
754    pub parse_buffer_bytes: u64,
755
756    /// Node extraction buffer size in bytes
757    pub symbol_buffer_bytes: u64,
758
759    /// LRU cache capacity for parsed ASTs
760    pub ast_cache_capacity: u64,
761
762    /// Query result buffer initial capacity
763    pub query_result_capacity: u64,
764
765    /// Watch event queue capacity
766    pub watch_event_queue_size: u64,
767
768    /// Channel capacity for background workers
769    pub channel_capacity: u64,
770
771    /// Read buffer size in bytes
772    pub read_buffer_bytes: u64,
773
774    /// Write buffer size in bytes
775    pub write_buffer_bytes: u64,
776
777    /// Index buffer size in bytes
778    pub index_buffer_bytes: u64,
779}
780
781impl Default for BuffersConfig {
782    fn default() -> Self {
783        Self {
784            parse_buffer_bytes: 1024 * 1024, // 1 MB
785            symbol_buffer_bytes: 512 * 1024, // 512 KB
786            ast_cache_capacity: 100,         // 100 entries
787            query_result_capacity: 1000,     // 1000 results
788            watch_event_queue_size: 10_000,  // 10k events
789            channel_capacity: 1000,          // 1000 items
790            read_buffer_bytes: 8192,         // 8 KB
791            write_buffer_bytes: 8192,        // 8 KB
792            index_buffer_bytes: 1024 * 1024, // 1 MB
793        }
794    }
795}
796
797// ============================================================================
798// Extensions
799// ============================================================================
800
801/// Custom extensions for future use
802#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
803#[serde(default)]
804pub struct GraphConfigExtensions {
805    /// Custom key-value pairs
806    #[serde(flatten)]
807    pub custom: BTreeMap<String, serde_json::Value>,
808}
809
810// ============================================================================
811// Tests
812// ============================================================================
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    #[test]
819    fn test_defaults_roundtrip() {
820        let config = GraphConfigFile::default();
821        let json = serde_json::to_string_pretty(&config).unwrap();
822        let parsed: GraphConfigFile = serde_json::from_str(&json).unwrap();
823        assert_eq!(config, parsed);
824    }
825
826    #[test]
827    fn test_schema_version_check() {
828        let config = GraphConfigFile::default();
829        assert!(config.validate_version().is_ok());
830
831        #[allow(clippy::field_reassign_with_default)]
832        let old_config = {
833            let mut c = GraphConfigFile::default();
834            c.schema_version = 0;
835            c
836        };
837        assert!(old_config.validate_version().is_err());
838    }
839
840    #[test]
841    fn test_validation_rejects_invalid_policy() {
842        let mut config = GraphConfigFile::default();
843        config.config.locking.stale_takeover_policy = "invalid".to_string();
844        assert!(config.validate().is_err());
845    }
846
847    #[test]
848    fn test_validation_accepts_valid_policies() {
849        for policy in ["deny", "warn", "allow"] {
850            let mut config = GraphConfigFile::default();
851            config.config.locking.stale_takeover_policy = policy.to_string();
852            assert!(config.validate().is_ok());
853        }
854    }
855
856    #[test]
857    fn test_validation_rejects_zero_page_size() {
858        let mut config = GraphConfigFile::default();
859        config.config.output.page_size = 0;
860        assert!(config.validate().is_err());
861    }
862
863    #[test]
864    #[allow(clippy::field_reassign_with_default)]
865    fn test_limits_zero_means_unlimited() {
866        let mut config = LimitsConfig::default();
867        config.max_results = 0;
868        config.max_depth = 0;
869        config.max_bytes_per_file = 0;
870        config.max_files = 0;
871        config.max_repositories = 0;
872        config.max_query_length = 0;
873        assert!(config.validate().is_ok());
874    }
875
876    #[test]
877    fn test_alias_entry_creation() {
878        let alias = AliasEntry::new("kind:function", Some("Find functions".to_string()));
879        assert_eq!(alias.query, "kind:function");
880        assert_eq!(alias.description, Some("Find functions".to_string()));
881        assert!(!alias.created_at.is_empty());
882        assert!(!alias.updated_at.is_empty());
883    }
884
885    #[test]
886    fn test_metadata_timestamps() {
887        let metadata = GraphConfigMetadata::default();
888        assert!(!metadata.created_at.is_empty());
889        assert!(!metadata.updated_at.is_empty());
890    }
891
892    #[test]
893    fn test_written_by_has_version() {
894        let written_by = WrittenByInfo::default();
895        assert!(!written_by.sqry_version.is_empty());
896    }
897
898    #[test]
899    fn test_extensions_empty_by_default() {
900        let ext = GraphConfigExtensions::default();
901        assert!(ext.custom.is_empty());
902    }
903
904    #[test]
905    fn test_full_validation() {
906        let config = GraphConfigFile::default();
907        assert!(config.validate().is_ok());
908    }
909}