Skip to main content

seher/config/
mod.rs

1use chrono::{DateTime, Local};
2use jsonc_parser::cst::{
3    CstArray, CstContainerNode, CstInputValue, CstLeafNode, CstNode, CstObject, CstRootNode,
4};
5use serde::{Deserialize, Serialize};
6use std::collections::HashMap;
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Deserialize, Serialize, Clone)]
10pub struct Settings {
11    #[serde(default, skip_serializing_if = "Vec::is_empty")]
12    pub priority: Vec<PriorityRule>,
13    pub agents: Vec<AgentConfig>,
14    #[serde(skip)]
15    original_text: Option<String>,
16}
17
18/// Represents the three possible states of the `provider` field:
19/// - `Inferred`: field absent -> provider is inferred from the command name
20/// - `Explicit(name)`: field has a string value -> use that provider name
21/// - `None`: field is `null` -> no provider (fallback agent)
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub enum ProviderConfig {
24    Inferred,
25    Explicit(String),
26    None,
27}
28
29impl<'de> serde::Deserialize<'de> for ProviderConfig {
30    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
31    where
32        D: serde::Deserializer<'de>,
33    {
34        let opt: Option<String> = serde::Deserialize::deserialize(deserializer)?;
35        Ok(match opt {
36            Some(s) => ProviderConfig::Explicit(s),
37            Option::None => ProviderConfig::None,
38        })
39    }
40}
41
42impl serde::Serialize for ProviderConfig {
43    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
44    where
45        S: serde::Serializer,
46    {
47        match self {
48            ProviderConfig::Explicit(s) => serializer.serialize_str(s),
49            ProviderConfig::Inferred | ProviderConfig::None => serializer.serialize_none(),
50        }
51    }
52}
53
54fn deserialize_provider_config<'de, D>(deserializer: D) -> Result<Option<ProviderConfig>, D::Error>
55where
56    D: serde::Deserializer<'de>,
57{
58    let config = ProviderConfig::deserialize(deserializer)?;
59    Ok(Some(config))
60}
61
62#[expect(
63    clippy::ref_option,
64    reason = "&Option<T> is required by serde skip_serializing_if"
65)]
66fn is_inferred_or_absent_provider(value: &Option<ProviderConfig>) -> bool {
67    matches!(value, Option::None | Some(ProviderConfig::Inferred))
68}
69
70#[expect(
71    clippy::ref_option,
72    reason = "&Option<T> is required by serde serialize_with"
73)]
74fn serialize_provider_config<S>(
75    value: &Option<ProviderConfig>,
76    serializer: S,
77) -> Result<S::Ok, S::Error>
78where
79    S: serde::Serializer,
80{
81    match value {
82        Some(ProviderConfig::Explicit(s)) => serializer.serialize_str(s),
83        Option::None | Some(ProviderConfig::Inferred | ProviderConfig::None) => {
84            serializer.serialize_none()
85        }
86    }
87}
88
89#[derive(Debug, Deserialize, Serialize, Clone)]
90pub struct AgentConfig {
91    pub command: String,
92    #[serde(default, skip_serializing_if = "Vec::is_empty")]
93    pub args: Vec<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub models: Option<HashMap<String, String>>,
96    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
97    pub arg_maps: HashMap<String, Vec<String>>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub env: Option<HashMap<String, String>>,
100    #[serde(
101        default,
102        deserialize_with = "deserialize_provider_config",
103        serialize_with = "serialize_provider_config",
104        skip_serializing_if = "is_inferred_or_absent_provider"
105    )]
106    pub provider: Option<ProviderConfig>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub openrouter_management_key: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub glm_api_key: Option<String>,
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub pre_command: Vec<String>,
113}
114
115#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
116pub struct PriorityRule {
117    pub command: String,
118    #[serde(
119        default,
120        deserialize_with = "deserialize_provider_config",
121        serialize_with = "serialize_provider_config",
122        skip_serializing_if = "is_inferred_or_absent_provider"
123    )]
124    pub provider: Option<ProviderConfig>,
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub model: Option<String>,
127    pub priority: i32,
128    /// Weekday ranges in "start-end" format (0=Sun, 1=Mon, ..., 6=Sat, inclusive).
129    /// e.g. `["1-5"]` means Monday through Friday.
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub weekdays: Option<Vec<String>>,
132    /// Hour ranges in "start-end" format, half-open [start, end), 0-48.
133    /// e.g. `["21-27"]` means 21:00 to 03:00 next day.
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub hours: Option<Vec<String>>,
136}
137
138fn command_to_provider(command: &str) -> Option<&str> {
139    match command {
140        "claude" => Some("claude"),
141        "codex" => Some("codex"),
142        "copilot" => Some("copilot"),
143        "glm" => Some("glm"),
144        "zai" => Some("zai"),
145        "kimi-k2" => Some("kimi-k2"),
146        "warp" => Some("warp"),
147        "kiro" => Some("kiro"),
148        _ => None,
149    }
150}
151
152fn resolve_provider<'a>(command: &'a str, provider: Option<&'a ProviderConfig>) -> Option<&'a str> {
153    match provider {
154        Some(ProviderConfig::Explicit(name)) => Some(name.as_str()),
155        Some(ProviderConfig::None) => Option::None,
156        Some(ProviderConfig::Inferred) | Option::None => command_to_provider(command),
157    }
158}
159
160fn provider_to_domain(provider: &str) -> Option<&str> {
161    match provider {
162        "claude" => Some("claude.ai"),
163        "codex" => Some("chatgpt.com"),
164        "copilot" => Some("github.com"),
165        _ => None,
166    }
167}
168
169impl AgentConfig {
170    #[must_use]
171    pub fn resolve_provider(&self) -> Option<&str> {
172        resolve_provider(&self.command, self.provider.as_ref())
173    }
174
175    #[must_use]
176    pub fn resolve_domain(&self) -> Option<&str> {
177        self.resolve_provider().and_then(provider_to_domain)
178    }
179
180    #[must_use]
181    pub fn has_model(&self, model_key: &str) -> bool {
182        self.models
183            .as_ref()
184            .is_none_or(|m| m.contains_key(model_key))
185    }
186}
187
188impl PriorityRule {
189    #[must_use]
190    pub fn resolve_provider(&self) -> Option<&str> {
191        resolve_provider(&self.command, self.provider.as_ref())
192    }
193
194    #[must_use]
195    pub fn matches(&self, command: &str, provider: Option<&str>, model: Option<&str>) -> bool {
196        self.command == command
197            && self.resolve_provider() == provider
198            && self.model.as_deref() == model
199    }
200
201    /// Like [`matches`], but also evaluates `weekdays`/`hours` schedule conditions
202    /// against the given local timestamp.
203    #[must_use]
204    pub fn matches_at(
205        &self,
206        command: &str,
207        provider: Option<&str>,
208        model: Option<&str>,
209        now: &DateTime<Local>,
210    ) -> bool {
211        use chrono::{Datelike, Timelike};
212
213        if !self.matches(command, provider, model) {
214            return false;
215        }
216
217        let current_hour = now.hour();
218        let current_weekday = now.weekday().num_days_from_sunday(); // 0=Sun..6=Sat
219
220        // Check hours constraint (OR logic across multiple ranges)
221        if let Some(hour_ranges) = &self.hours {
222            let hour_matched = hour_ranges.iter().any(|range_str| {
223                let Some((start, end)) = parse_schedule_range(range_str) else {
224                    return false;
225                };
226                // Direct match: current_hour in [start, end)
227                if current_hour >= start && current_hour < end {
228                    weekday_in_ranges(current_weekday, self.weekdays.as_deref())
229                }
230                // Overnight match: (current_hour + 24) in [start, end)
231                else if end > 24 {
232                    let shifted = current_hour + 24;
233                    if shifted >= start && shifted < end {
234                        // Use previous day's weekday
235                        let prev_weekday = if current_weekday == 0 {
236                            6
237                        } else {
238                            current_weekday - 1
239                        };
240                        weekday_in_ranges(prev_weekday, self.weekdays.as_deref())
241                    } else {
242                        false
243                    }
244                } else {
245                    false
246                }
247            });
248            if !hour_matched {
249                return false;
250            }
251        } else if !weekday_in_ranges(current_weekday, self.weekdays.as_deref()) {
252            // No hours constraint; check weekdays alone if present
253            return false;
254        }
255
256        true
257    }
258
259    /// Returns the number of schedule axes constrained by this rule (0, 1, or 2).
260    /// Used for conflict resolution: rules with more constraints win over less specific ones.
261    #[must_use]
262    pub fn schedule_specificity(&self) -> u8 {
263        u8::from(self.weekdays.is_some()) + u8::from(self.hours.is_some())
264    }
265}
266
267impl Default for Settings {
268    fn default() -> Self {
269        Self {
270            priority: vec![],
271            agents: vec![AgentConfig {
272                command: "claude".to_string(),
273                args: vec![],
274                models: None,
275                arg_maps: HashMap::new(),
276                env: None,
277                provider: None,
278                openrouter_management_key: None,
279                glm_api_key: None,
280                pre_command: vec![],
281            }],
282            original_text: None,
283        }
284    }
285}
286
287fn merge_cst_node(cst_node: &CstNode, new_val: &serde_json::Value) {
288    match new_val {
289        serde_json::Value::Object(obj) => {
290            if let Some(cst_obj) = cst_node.as_object() {
291                merge_cst_object(&cst_obj, obj);
292                return;
293            }
294        }
295        serde_json::Value::Array(arr) => {
296            if let Some(cst_arr) = cst_node.as_array() {
297                merge_cst_array(&cst_arr, arr);
298                return;
299            }
300        }
301        serde_json::Value::String(s) => {
302            if let Some(lit) = cst_node.as_string_lit() {
303                if lit.decoded_value().ok().as_deref() != Some(s.as_str())
304                    && let Ok(raw) = serde_json::to_string(s)
305                {
306                    lit.set_raw_value(raw);
307                }
308                return;
309            }
310        }
311        serde_json::Value::Number(n) => {
312            if let Some(lit) = cst_node.as_number_lit() {
313                let new_text = n.to_string();
314                if lit.to_string() != new_text {
315                    lit.set_raw_value(new_text);
316                }
317                return;
318            }
319        }
320        serde_json::Value::Bool(b) => {
321            if let Some(lit) = cst_node.as_boolean_lit() {
322                if lit.value() != *b {
323                    lit.set_value(*b);
324                }
325                return;
326            }
327        }
328        serde_json::Value::Null => {
329            if cst_node.as_null_keyword().is_some() {
330                return;
331            }
332        }
333    }
334    // Type mismatch: replace the node entirely
335    let replacement = serde_value_to_cst_input(new_val);
336    if let Some(prop) = cst_node.parent().and_then(|p| p.as_object_prop()) {
337        prop.set_value(replacement);
338    } else {
339        // Array element or other context: use type-specific replace_with
340        replace_cst_node(cst_node.clone(), replacement);
341    }
342}
343
344fn replace_cst_node(node: CstNode, replacement: CstInputValue) {
345    match node {
346        CstNode::Leaf(leaf) => match leaf {
347            CstLeafNode::StringLit(n) => {
348                n.replace_with(replacement);
349            }
350            CstLeafNode::NumberLit(n) => {
351                n.replace_with(replacement);
352            }
353            CstLeafNode::BooleanLit(n) => {
354                n.replace_with(replacement);
355            }
356            CstLeafNode::NullKeyword(n) => {
357                n.replace_with(replacement);
358            }
359            CstLeafNode::WordLit(_)
360            | CstLeafNode::Token(_)
361            | CstLeafNode::Whitespace(_)
362            | CstLeafNode::Newline(_)
363            | CstLeafNode::Comment(_) => {}
364        },
365        CstNode::Container(container) => match container {
366            CstContainerNode::Object(n) => {
367                n.replace_with(replacement);
368            }
369            CstContainerNode::Array(n) => {
370                n.replace_with(replacement);
371            }
372            CstContainerNode::Root(_) | CstContainerNode::ObjectProp(_) => {}
373        },
374    }
375}
376
377fn merge_cst_object(cst_obj: &CstObject, new_obj: &serde_json::Map<String, serde_json::Value>) {
378    for (key, val) in new_obj {
379        if let Some(prop) = cst_obj.get(key) {
380            if let Some(existing) = prop.value() {
381                merge_cst_node(&existing, val);
382            } else {
383                prop.set_value(serde_value_to_cst_input(val));
384            }
385        } else {
386            cst_obj.append(key, serde_value_to_cst_input(val));
387        }
388    }
389    let props_to_remove: Vec<_> = cst_obj
390        .properties()
391        .into_iter()
392        .filter(|prop| {
393            prop.name()
394                .and_then(|n| n.decoded_value().ok())
395                .is_some_and(|name| !new_obj.contains_key(&name))
396        })
397        .collect();
398    for prop in props_to_remove {
399        prop.remove();
400    }
401}
402
403fn merge_cst_array(cst_arr: &CstArray, new_arr: &[serde_json::Value]) {
404    let elements = cst_arr.elements();
405    let existing_len = elements.len();
406    let new_len = new_arr.len();
407
408    // Update existing elements in place
409    for (i, new_val) in new_arr.iter().enumerate().take(existing_len) {
410        merge_cst_node(&elements[i], new_val);
411    }
412
413    // Remove extra elements from the end (in reverse to keep indices stable)
414    for element in elements.into_iter().skip(new_len).rev() {
415        element.remove();
416    }
417
418    // Append new elements
419    for new_val in new_arr.iter().skip(existing_len) {
420        cst_arr.append(serde_value_to_cst_input(new_val));
421    }
422}
423
424fn serde_value_to_cst_input(val: &serde_json::Value) -> CstInputValue {
425    match val {
426        serde_json::Value::Null => CstInputValue::Null,
427        serde_json::Value::Bool(b) => CstInputValue::Bool(*b),
428        serde_json::Value::Number(n) => CstInputValue::Number(n.to_string()),
429        serde_json::Value::String(s) => CstInputValue::String(s.clone()),
430        serde_json::Value::Array(arr) => {
431            CstInputValue::Array(arr.iter().map(serde_value_to_cst_input).collect())
432        }
433        serde_json::Value::Object(obj) => CstInputValue::Object(
434            obj.iter()
435                .map(|(k, v)| (k.clone(), serde_value_to_cst_input(v)))
436                .collect(),
437        ),
438    }
439}
440
441fn strip_trailing_commas(s: &str) -> String {
442    let chars: Vec<char> = s.chars().collect();
443    let mut result = String::with_capacity(s.len());
444    let mut i = 0;
445    let mut in_string = false;
446
447    while i < chars.len() {
448        let c = chars[i];
449
450        if in_string {
451            result.push(c);
452            if c == '\\' && i + 1 < chars.len() {
453                i += 1;
454                result.push(chars[i]);
455            } else if c == '"' {
456                in_string = false;
457            }
458        } else if c == '"' {
459            in_string = true;
460            result.push(c);
461        } else if c == ',' {
462            let mut j = i + 1;
463            while j < chars.len() && chars[j].is_whitespace() {
464                j += 1;
465            }
466            if j < chars.len() && (chars[j] == ']' || chars[j] == '}') {
467                // trailing comma: skip it
468            } else {
469                result.push(c);
470            }
471        } else {
472            result.push(c);
473        }
474
475        i += 1;
476    }
477
478    result
479}
480
481/// Parse a schedule range string like "start-end" into `(start, end)`.
482/// Returns `None` if the string is not parseable.
483/// Validation (start < end, bounds) is done separately in `validate_priority_schedule`.
484fn parse_schedule_range(s: &str) -> Option<(u32, u32)> {
485    let (start_str, end_str) = s.split_once('-')?;
486    let start: u32 = start_str.parse().ok()?;
487    let end: u32 = end_str.parse().ok()?;
488    Some((start, end))
489}
490
491/// Returns `true` if `weekday` falls within any range in `ranges`, or if `ranges` is `None`.
492fn weekday_in_ranges(weekday: u32, ranges: Option<&[String]>) -> bool {
493    if let Some(wd_ranges) = ranges {
494        wd_ranges.iter().any(|wd_str| {
495            let Some((ws, we)) = parse_schedule_range(wd_str) else {
496                return false;
497            };
498            weekday >= ws && weekday <= we
499        })
500    } else {
501        true
502    }
503}
504
505impl Settings {
506    /// Evaluates schedule conditions against `now`.
507    /// Among all matching rules, the one with the highest schedule specificity wins;
508    /// ties are broken by first occurrence (stable, original-order compatible).
509    #[must_use]
510    pub fn priority_for_at(
511        &self,
512        agent: &AgentConfig,
513        model: Option<&str>,
514        now: &DateTime<Local>,
515    ) -> i32 {
516        self.priority_for_components_at(&agent.command, agent.resolve_provider(), model, now)
517    }
518
519    /// Among all matching rules, the one with the highest schedule specificity wins;
520    /// ties are broken by first occurrence (stable, original-order compatible).
521    #[must_use]
522    pub fn priority_for_components_at(
523        &self,
524        command: &str,
525        provider: Option<&str>,
526        model: Option<&str>,
527        now: &DateTime<Local>,
528    ) -> i32 {
529        // Among all matching rules, pick the one with the highest schedule_specificity.
530        // In case of a tie, the first occurrence wins (stable, original-order compatible).
531        self.priority
532            .iter()
533            .filter(|rule| rule.matches_at(command, provider, model, now))
534            .fold(None::<&PriorityRule>, |best, rule| match best {
535                None => Some(rule),
536                Some(b) if rule.schedule_specificity() > b.schedule_specificity() => Some(rule),
537                Some(b) => Some(b),
538            })
539            .map_or(0, |rule| rule.priority)
540    }
541
542    fn validate_priority_schedule(&self) -> Result<(), Box<dyn std::error::Error>> {
543        for rule in &self.priority {
544            if let Some(hour_ranges) = &rule.hours {
545                for range_str in hour_ranges {
546                    let (start, end) = parse_schedule_range(range_str)
547                        .ok_or_else(|| format!("invalid hours range: {range_str:?}"))?;
548                    if start >= end {
549                        return Err(format!(
550                            "invalid hours range {range_str:?}: start must be less than end"
551                        )
552                        .into());
553                    }
554                    if end > 48 {
555                        return Err(format!(
556                            "invalid hours range {range_str:?}: end must not exceed 48"
557                        )
558                        .into());
559                    }
560                }
561            }
562            if let Some(wd_ranges) = &rule.weekdays {
563                for range_str in wd_ranges {
564                    let (start, end) = parse_schedule_range(range_str)
565                        .ok_or_else(|| format!("invalid weekdays range: {range_str:?}"))?;
566                    if start > end {
567                        return Err(format!(
568                            "invalid weekdays range {range_str:?}: start must not exceed end"
569                        )
570                        .into());
571                    }
572                    if end > 6 {
573                        return Err(format!(
574                            "invalid weekdays range {range_str:?}: end must not exceed 6"
575                        )
576                        .into());
577                    }
578                }
579            }
580        }
581        Ok(())
582    }
583
584    /// # Errors
585    ///
586    /// Returns an error if the settings file cannot be read or parsed.
587    pub fn load(path: Option<&Path>) -> Result<Self, Box<dyn std::error::Error>> {
588        let path = match path {
589            Some(p) => p.to_path_buf(),
590            None => Self::settings_path()?,
591        };
592        let content = match std::fs::read_to_string(&path) {
593            Ok(c) => c,
594            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
595                return Ok(Settings::default());
596            }
597            Err(e) => return Err(e.into()),
598        };
599        let mut stripped = json_comments::StripComments::new(content.as_bytes());
600        let mut json_str = String::new();
601        std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
602        let clean = strip_trailing_commas(&json_str);
603        let mut settings: Settings = serde_json::from_str(&clean)?;
604        settings.validate_priority_schedule()?;
605        settings.original_text = Some(content);
606        Ok(settings)
607    }
608
609    fn save_with_cst(&self, original: &str) -> Result<String, Box<dyn std::error::Error>> {
610        let root = CstRootNode::parse(original, &jsonc_parser::ParseOptions::default())
611            .map_err(|e| e.to_string())?;
612        let root_obj = root.object_value_or_set();
613
614        let value = serde_json::to_value(self)?;
615        let obj = value
616            .as_object()
617            .ok_or("settings serialized to non-object")?;
618
619        merge_cst_object(&root_obj, obj);
620
621        Ok(root.to_string())
622    }
623
624    /// # Errors
625    ///
626    /// Returns an error if serialization or file writing fails.
627    pub fn save(&self, path: Option<&Path>) -> Result<(), Box<dyn std::error::Error>> {
628        let path = match path {
629            Some(p) => p.to_path_buf(),
630            None => Self::settings_path()?,
631        };
632        let output = match &self.original_text {
633            Some(original) => self
634                .save_with_cst(original)
635                .or_else(|_| serde_json::to_string_pretty(self))?,
636            None => serde_json::to_string_pretty(self)?,
637        };
638        let parent = path.parent().unwrap_or_else(|| std::path::Path::new("."));
639        std::fs::create_dir_all(parent)?;
640        let mut tmp = tempfile::NamedTempFile::new_in(parent)?;
641        std::io::Write::write_all(&mut tmp, output.as_bytes())?;
642        std::io::Write::flush(&mut tmp)?;
643        tmp.persist(&path).map_err(|e| e.error)?;
644        Ok(())
645    }
646
647    /// Upsert a `PriorityRule`. If a matching rule (command + provider + model) already exists,
648    /// its priority is updated. Otherwise a new rule is appended.
649    pub fn upsert_priority(
650        &mut self,
651        command: &str,
652        provider: Option<ProviderConfig>,
653        model: Option<String>,
654        priority: i32,
655    ) {
656        for rule in &mut self.priority {
657            if rule.command == command && rule.provider == provider && rule.model == model {
658                rule.priority = priority;
659                return;
660            }
661        }
662        self.priority.push(PriorityRule {
663            command: command.to_string(),
664            provider,
665            model,
666            priority,
667            weekdays: None,
668            hours: None,
669        });
670    }
671
672    /// Remove a `PriorityRule` matching the given (command, provider, model) triple.
673    pub fn remove_priority(
674        &mut self,
675        command: &str,
676        provider: Option<&ProviderConfig>,
677        model: Option<&str>,
678    ) {
679        self.priority.retain(|rule| {
680            !(rule.command == command
681                && rule.provider.as_ref() == provider
682                && rule.model.as_deref() == model)
683        });
684    }
685
686    fn settings_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
687        let home = dirs::home_dir().ok_or("HOME directory not found")?;
688        let dir = home.join(".config").join("seher");
689        let jsonc_path = dir.join("settings.jsonc");
690        if jsonc_path.exists() {
691            return Ok(jsonc_path);
692        }
693        Ok(dir.join("settings.json"))
694    }
695}
696
697/// Construct a local `DateTime` for testing without DST ambiguity (January = no DST).
698#[cfg(test)]
699#[expect(clippy::unwrap_used, reason = "test helper")]
700pub(crate) fn make_local_dt(year: i32, month: u32, day: u32, hour: u32) -> DateTime<Local> {
701    use chrono::TimeZone;
702    let naive = chrono::NaiveDateTime::new(
703        chrono::NaiveDate::from_ymd_opt(year, month, day).unwrap(),
704        chrono::NaiveTime::from_hms_opt(hour, 0, 0).unwrap(),
705    );
706    Local
707        .from_local_datetime(&naive)
708        .single()
709        .unwrap_or_else(|| Local.from_local_datetime(&naive).latest().unwrap())
710}
711
712#[cfg(test)]
713mod tests {
714    use super::*;
715
716    type TestResult = Result<(), Box<dyn std::error::Error>>;
717
718    fn sample_settings_path() -> PathBuf {
719        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
720            .join("examples")
721            .join("settings.json")
722    }
723
724    fn load_sample() -> Result<Settings, Box<dyn std::error::Error>> {
725        let content = std::fs::read_to_string(sample_settings_path())?;
726        let settings: Settings = serde_json::from_str(&content)?;
727        Ok(settings)
728    }
729
730    #[test]
731    fn test_parse_sample_settings() -> TestResult {
732        let settings = load_sample()?;
733
734        assert_eq!(settings.priority.len(), 4);
735        assert_eq!(settings.agents.len(), 4);
736        Ok(())
737    }
738
739    #[test]
740    fn test_sample_settings_priority_rules() -> TestResult {
741        let settings = load_sample()?;
742
743        assert_eq!(
744            settings.priority[0],
745            PriorityRule {
746                command: "opencode".to_string(),
747                provider: Some(ProviderConfig::Explicit("copilot".to_string())),
748                model: Some("high".to_string()),
749                priority: 100,
750                weekdays: None,
751                hours: None,
752            }
753        );
754        assert_eq!(
755            settings.priority[2],
756            PriorityRule {
757                command: "claude".to_string(),
758                provider: Some(ProviderConfig::None),
759                model: Some("medium".to_string()),
760                priority: 25,
761                weekdays: None,
762                hours: None,
763            }
764        );
765        Ok(())
766    }
767
768    #[test]
769    fn test_sample_settings_claude_agent() -> TestResult {
770        let settings = load_sample()?;
771
772        let claude = &settings.agents[0];
773        assert_eq!(claude.command, "claude");
774        assert_eq!(claude.args, ["--model", "{model}"]);
775
776        let models = claude.models.as_ref();
777        assert!(models.is_some());
778        let models = models.ok_or("models should be present")?;
779        assert_eq!(models.get("high").map(String::as_str), Some("opus"));
780        assert_eq!(models.get("medium").map(String::as_str), Some("sonnet"));
781        assert_eq!(
782            claude.arg_maps.get("--danger").cloned(),
783            Some(vec![
784                "--permission-mode".to_string(),
785                "bypassPermissions".to_string(),
786            ])
787        );
788
789        // no provider field -> None (inferred from command name)
790        assert!(claude.provider.is_none());
791        assert_eq!(claude.resolve_domain(), Some("claude.ai"));
792        Ok(())
793    }
794
795    #[test]
796    fn test_sample_settings_copilot_agent() -> TestResult {
797        let settings = load_sample()?;
798
799        let opencode = &settings.agents[1];
800        assert_eq!(opencode.command, "opencode");
801        assert_eq!(opencode.args, ["--model", "{model}", "--yolo"]);
802
803        let models = opencode.models.as_ref().ok_or("models should be present")?;
804        assert_eq!(
805            models.get("high").map(String::as_str),
806            Some("github-copilot/gpt-5.4")
807        );
808        assert_eq!(
809            models.get("low").map(String::as_str),
810            Some("github-copilot/claude-haiku-4.5")
811        );
812
813        // provider: "copilot" -> Some(Explicit("copilot"))
814        assert_eq!(
815            opencode.provider,
816            Some(ProviderConfig::Explicit("copilot".to_string()))
817        );
818        assert_eq!(opencode.resolve_domain(), Some("github.com"));
819        Ok(())
820    }
821
822    #[test]
823    fn test_sample_settings_fallback_agent() -> TestResult {
824        let settings = load_sample()?;
825
826        let fallback = &settings.agents[3];
827        assert_eq!(fallback.command, "claude");
828
829        // provider: null -> Some(ProviderConfig::None) (fallback)
830        assert_eq!(fallback.provider, Some(ProviderConfig::None));
831        assert_eq!(fallback.resolve_domain(), None);
832        Ok(())
833    }
834
835    #[test]
836    fn test_sample_settings_codex_agent() -> TestResult {
837        let settings = load_sample()?;
838
839        let codex = &settings.agents[2];
840        assert_eq!(codex.command, "codex");
841        assert!(codex.args.is_empty());
842        assert!(codex.models.is_none());
843        assert!(codex.provider.is_none());
844        assert_eq!(codex.resolve_domain(), Some("chatgpt.com"));
845        assert_eq!(codex.pre_command, ["git", "pull", "--rebase"]);
846        Ok(())
847    }
848
849    #[test]
850    fn test_provider_field_absent() -> TestResult {
851        let json = r#"{"agents": [{"command": "claude"}]}"#;
852        let settings: Settings = serde_json::from_str(json)?;
853
854        assert!(settings.agents[0].provider.is_none());
855        assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
856        assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
857        Ok(())
858    }
859
860    #[test]
861    fn test_provider_field_null() -> TestResult {
862        let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
863        let settings: Settings = serde_json::from_str(json)?;
864
865        assert_eq!(settings.agents[0].provider, Some(ProviderConfig::None));
866        assert_eq!(settings.agents[0].resolve_provider(), None);
867        assert_eq!(settings.agents[0].resolve_domain(), None);
868        Ok(())
869    }
870
871    #[test]
872    fn test_provider_field_string() -> TestResult {
873        let json = r#"{"agents": [{"command": "opencode", "provider": "copilot"}]}"#;
874        let settings: Settings = serde_json::from_str(json)?;
875
876        assert_eq!(
877            settings.agents[0].provider,
878            Some(ProviderConfig::Explicit("copilot".to_string()))
879        );
880        assert_eq!(settings.agents[0].resolve_provider(), Some("copilot"));
881        assert_eq!(settings.agents[0].resolve_domain(), Some("github.com"));
882        Ok(())
883    }
884
885    #[test]
886    fn test_priority_defaults_to_empty() {
887        let settings = Settings::default();
888
889        assert!(settings.priority.is_empty());
890    }
891
892    #[test]
893    fn test_priority_defaults_to_zero_when_no_rule_matches() -> TestResult {
894        let json = r#"{"priority": [{"command": "claude", "model": "high", "priority": 10}], "agents": [{"command": "codex"}]}"#;
895        let settings: Settings = serde_json::from_str(json)?;
896        let now = make_local_dt(2024, 1, 8, 10);
897
898        assert_eq!(
899            settings.priority_for_at(&settings.agents[0], Some("high"), &now),
900            0
901        );
902        assert_eq!(
903            settings.priority_for_components_at("claude", Some("claude"), None, &now),
904            0
905        );
906        Ok(())
907    }
908
909    #[test]
910    fn test_priority_matches_inferred_provider_and_model() -> TestResult {
911        let json = r#"{
912            "priority": [
913                {"command": "claude", "model": "high", "priority": 42}
914            ],
915            "agents": [{"command": "claude"}]
916        }"#;
917        let settings: Settings = serde_json::from_str(json)?;
918        let now = make_local_dt(2024, 1, 8, 10);
919
920        assert_eq!(
921            settings.priority_for_at(&settings.agents[0], Some("high"), &now),
922            42
923        );
924        Ok(())
925    }
926
927    #[test]
928    fn test_priority_matches_null_provider_for_fallback_agent() -> TestResult {
929        let json = r#"{
930            "priority": [
931                {"command": "claude", "provider": null, "model": "medium", "priority": 25}
932            ],
933            "agents": [{"command": "claude", "provider": null}]
934        }"#;
935        let settings: Settings = serde_json::from_str(json)?;
936        let now = make_local_dt(2024, 1, 8, 10);
937
938        assert_eq!(
939            settings.priority_for_at(&settings.agents[0], Some("medium"), &now),
940            25
941        );
942        Ok(())
943    }
944
945    #[test]
946    fn test_priority_supports_full_i32_range() -> TestResult {
947        let json = r#"{
948            "priority": [
949                {"command": "claude", "model": "high", "priority": 2147483647},
950                {"command": "claude", "provider": null, "priority": -2147483648}
951            ],
952            "agents": [
953                {"command": "claude"},
954                {"command": "claude", "provider": null}
955            ]
956        }"#;
957        let settings: Settings = serde_json::from_str(json)?;
958        let now = make_local_dt(2024, 1, 8, 10);
959
960        assert_eq!(
961            settings.priority_for_at(&settings.agents[0], Some("high"), &now),
962            i32::MAX
963        );
964        assert_eq!(
965            settings.priority_for_at(&settings.agents[1], None, &now),
966            i32::MIN
967        );
968        Ok(())
969    }
970
971    #[test]
972    fn test_command_codex_resolves_chatgpt_domain() -> TestResult {
973        let json = r#"{"agents": [{"command": "codex"}]}"#;
974        let settings: Settings = serde_json::from_str(json)?;
975
976        assert!(settings.agents[0].provider.is_none());
977        assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
978        Ok(())
979    }
980
981    #[test]
982    fn test_provider_field_codex_string() -> TestResult {
983        let json = r#"{"agents": [{"command": "opencode", "provider": "codex"}]}"#;
984        let settings: Settings = serde_json::from_str(json)?;
985
986        assert_eq!(
987            settings.agents[0].provider,
988            Some(ProviderConfig::Explicit("codex".to_string()))
989        );
990        assert_eq!(settings.agents[0].resolve_domain(), Some("chatgpt.com"));
991        Ok(())
992    }
993
994    #[test]
995    fn test_provider_field_opencode_go_string() -> TestResult {
996        let json = r#"{"agents": [{"command": "opencode", "provider": "opencode-go"}]}"#;
997        let settings: Settings = serde_json::from_str(json)?;
998
999        assert_eq!(
1000            settings.agents[0].provider,
1001            Some(ProviderConfig::Explicit("opencode-go".to_string()))
1002        );
1003        assert_eq!(settings.agents[0].resolve_provider(), Some("opencode-go"));
1004        assert_eq!(settings.agents[0].resolve_domain(), None);
1005        Ok(())
1006    }
1007
1008    #[test]
1009    fn test_provider_unknown_string() -> TestResult {
1010        let json = r#"{"agents": [{"command": "someai", "provider": "unknown"}]}"#;
1011        let settings: Settings = serde_json::from_str(json)?;
1012
1013        assert_eq!(
1014            settings.agents[0].provider,
1015            Some(ProviderConfig::Explicit("unknown".to_string()))
1016        );
1017        assert_eq!(settings.agents[0].resolve_domain(), None);
1018        Ok(())
1019    }
1020
1021    #[test]
1022    fn test_parse_minimal_settings_without_models() -> TestResult {
1023        let json = r#"{"agents": [{"command": "claude"}]}"#;
1024        let settings: Settings = serde_json::from_str(json)?;
1025
1026        assert_eq!(settings.agents.len(), 1);
1027        assert_eq!(settings.agents[0].command, "claude");
1028        assert!(settings.agents[0].args.is_empty());
1029        assert!(settings.agents[0].models.is_none());
1030        assert!(settings.agents[0].arg_maps.is_empty());
1031        Ok(())
1032    }
1033
1034    #[test]
1035    fn test_parse_settings_with_env() -> TestResult {
1036        let json = r#"{"agents": [{"command": "claude", "env": {"ANTHROPIC_API_KEY": "sk-test", "CLAUDE_CODE_MAX_TURNS": "100"}}]}"#;
1037        let settings: Settings = serde_json::from_str(json)?;
1038
1039        let env = settings.agents[0]
1040            .env
1041            .as_ref()
1042            .ok_or("env should be present")?;
1043        assert_eq!(
1044            env.get("ANTHROPIC_API_KEY").map(String::as_str),
1045            Some("sk-test")
1046        );
1047        assert_eq!(env.get("CLAUDE_CODE_MAX_HOURS").map(String::as_str), None);
1048        assert_eq!(
1049            env.get("CLAUDE_CODE_MAX_TURNS").map(String::as_str),
1050            Some("100")
1051        );
1052        Ok(())
1053    }
1054
1055    #[test]
1056    fn test_parse_settings_with_args_no_models() -> TestResult {
1057        let json = r#"{"agents": [{"command": "claude", "args": ["--permission-mode", "bypassPermissions"]}]}"#;
1058        let settings: Settings = serde_json::from_str(json)?;
1059
1060        assert_eq!(
1061            settings.agents[0].args,
1062            ["--permission-mode", "bypassPermissions"]
1063        );
1064        assert!(settings.agents[0].models.is_none());
1065        assert!(settings.agents[0].arg_maps.is_empty());
1066        Ok(())
1067    }
1068
1069    #[test]
1070    fn test_parse_jsonc_with_comments() -> TestResult {
1071        let jsonc = r#"{
1072            // This is a comment
1073            "agents": [
1074                {
1075                    "command": "claude", /* inline comment */
1076                    "args": ["--model", "{model}"]
1077                }
1078            ]
1079        }"#;
1080        let stripped = json_comments::StripComments::new(jsonc.as_bytes());
1081        let settings: Settings = serde_json::from_reader(stripped)?;
1082        assert_eq!(settings.agents.len(), 1);
1083        assert_eq!(settings.agents[0].command, "claude");
1084        Ok(())
1085    }
1086
1087    #[test]
1088    fn test_parse_jsonc_with_trailing_commas() -> TestResult {
1089        let jsonc = r#"{
1090            // trailing commas
1091            "agents": [
1092                {
1093                    "command": "claude",
1094                    "args": ["--model", "{model}"],
1095                },
1096            ]
1097        }"#;
1098        let mut stripped = json_comments::StripComments::new(jsonc.as_bytes());
1099        let mut json_str = String::new();
1100        std::io::Read::read_to_string(&mut stripped, &mut json_str)?;
1101        let clean = strip_trailing_commas(&json_str);
1102        let settings: Settings = serde_json::from_str(&clean)?;
1103        assert_eq!(settings.agents.len(), 1);
1104        assert_eq!(settings.agents[0].command, "claude");
1105        Ok(())
1106    }
1107
1108    #[test]
1109    fn test_parse_settings_with_arg_maps() -> TestResult {
1110        let json = r#"{"agents": [{"command": "claude", "arg_maps": {"--danger": ["--permission-mode", "bypassPermissions"]}}]}"#;
1111        let settings: Settings = serde_json::from_str(json)?;
1112
1113        assert_eq!(
1114            settings.agents[0].arg_maps.get("--danger").cloned(),
1115            Some(vec![
1116                "--permission-mode".to_string(),
1117                "bypassPermissions".to_string(),
1118            ])
1119        );
1120        Ok(())
1121    }
1122
1123    #[test]
1124    fn test_parse_settings_with_openrouter_management_key() -> TestResult {
1125        // Given: agent config with openrouter provider and management key
1126        let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
1127
1128        // When: parsed
1129        let settings: Settings = serde_json::from_str(json)?;
1130
1131        // Then: key is correctly deserialized
1132        assert_eq!(
1133            settings.agents[0].openrouter_management_key.as_deref(),
1134            Some("sk-or-v1-abc123")
1135        );
1136        Ok(())
1137    }
1138
1139    #[test]
1140    fn test_openrouter_management_key_defaults_to_none_when_absent() -> TestResult {
1141        // Given: agent config without openrouter_management_key field
1142        let json = r#"{"agents": [{"command": "claude"}]}"#;
1143
1144        // When: parsed
1145        let settings: Settings = serde_json::from_str(json)?;
1146
1147        // Then: key defaults to None
1148        assert!(settings.agents[0].openrouter_management_key.is_none());
1149        Ok(())
1150    }
1151
1152    #[test]
1153    fn test_openrouter_provider_resolves_provider_but_not_domain() -> TestResult {
1154        // Given: agent with explicit "openrouter" provider (no cookie-based auth)
1155        let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
1156
1157        // When: provider and domain resolved
1158        let settings: Settings = serde_json::from_str(json)?;
1159
1160        // Then: provider resolves to "openrouter" but domain is None
1161        // (OpenRouter does not use browser cookies)
1162        assert_eq!(settings.agents[0].resolve_provider(), Some("openrouter"));
1163        assert_eq!(settings.agents[0].resolve_domain(), None);
1164        Ok(())
1165    }
1166
1167    #[test]
1168    fn test_parse_settings_with_pre_command() -> TestResult {
1169        let json =
1170            r#"{"agents": [{"command": "claude", "pre_command": ["git", "pull", "--rebase"]}]}"#;
1171        let settings: Settings = serde_json::from_str(json)?;
1172
1173        assert_eq!(settings.agents[0].pre_command, ["git", "pull", "--rebase"]);
1174        Ok(())
1175    }
1176
1177    #[test]
1178    fn test_pre_command_defaults_to_empty_when_absent() -> TestResult {
1179        let json = r#"{"agents": [{"command": "claude"}]}"#;
1180        let settings: Settings = serde_json::from_str(json)?;
1181
1182        assert!(settings.agents[0].pre_command.is_empty());
1183        Ok(())
1184    }
1185
1186    #[test]
1187    fn test_openrouter_management_key_is_ignored_for_other_providers() -> TestResult {
1188        // Given: claude agent config that happens to have openrouter_management_key set
1189        let json = r#"{"agents": [{"command": "claude", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
1190
1191        // When: parsed
1192        let settings: Settings = serde_json::from_str(json)?;
1193
1194        // Then: provider resolution is unaffected by the presence of openrouter_management_key
1195        assert_eq!(settings.agents[0].resolve_provider(), Some("claude"));
1196        assert_eq!(settings.agents[0].resolve_domain(), Some("claude.ai"));
1197        Ok(())
1198    }
1199
1200    // -- Serialize tests ------------------------------------------------------
1201
1202    #[test]
1203    fn test_serialize_roundtrip_sample_settings() -> TestResult {
1204        let settings = load_sample()?;
1205        let json = serde_json::to_string_pretty(&settings)?;
1206        let reparsed: Settings = serde_json::from_str(&json)?;
1207
1208        assert_eq!(reparsed.agents.len(), settings.agents.len());
1209        assert_eq!(reparsed.priority.len(), settings.priority.len());
1210        assert_eq!(reparsed.agents[0].command, settings.agents[0].command);
1211        Ok(())
1212    }
1213
1214    #[test]
1215    fn test_serialize_skips_empty_args() -> TestResult {
1216        let json = r#"{"agents": [{"command": "claude"}]}"#;
1217        let settings: Settings = serde_json::from_str(json)?;
1218        let out = serde_json::to_string(&settings)?;
1219        let val: serde_json::Value = serde_json::from_str(&out)?;
1220
1221        assert!(val["agents"][0]["args"].is_null());
1222        Ok(())
1223    }
1224
1225    #[test]
1226    fn test_serialize_null_provider_roundtrip() -> TestResult {
1227        let json = r#"{"agents": [{"command": "claude", "provider": null}]}"#;
1228        let settings: Settings = serde_json::from_str(json)?;
1229        let out = serde_json::to_string(&settings)?;
1230        let val: serde_json::Value = serde_json::from_str(&out)?;
1231
1232        assert!(val["agents"][0]["provider"].is_null());
1233        Ok(())
1234    }
1235
1236    #[test]
1237    fn test_serialize_inferred_provider_skipped() -> TestResult {
1238        let json = r#"{"agents": [{"command": "claude"}]}"#;
1239        let settings: Settings = serde_json::from_str(json)?;
1240        let out = serde_json::to_string(&settings)?;
1241        let val: serde_json::Value = serde_json::from_str(&out)?;
1242
1243        // provider field absent when inferred
1244        assert!(val["agents"][0]["provider"].is_null());
1245        Ok(())
1246    }
1247
1248    #[test]
1249    fn test_upsert_priority_creates_new_rule() {
1250        let mut settings = Settings::default();
1251        settings.upsert_priority("claude", None, Some("high".to_string()), 42);
1252
1253        assert_eq!(settings.priority.len(), 1);
1254        assert_eq!(settings.priority[0].priority, 42);
1255        assert_eq!(settings.priority[0].model.as_deref(), Some("high"));
1256    }
1257
1258    #[test]
1259    fn test_upsert_priority_updates_existing_rule() {
1260        let mut settings = Settings::default();
1261        settings.upsert_priority("claude", None, Some("high".to_string()), 10);
1262        settings.upsert_priority("claude", None, Some("high".to_string()), 99);
1263
1264        assert_eq!(settings.priority.len(), 1);
1265        assert_eq!(settings.priority[0].priority, 99);
1266    }
1267
1268    #[test]
1269    fn test_remove_priority_removes_matching_rule() {
1270        let mut settings = Settings::default();
1271        settings.upsert_priority("claude", None, Some("high".to_string()), 10);
1272        settings.upsert_priority("claude", None, Some("low".to_string()), 5);
1273        settings.remove_priority("claude", None, Some("high"));
1274
1275        assert_eq!(settings.priority.len(), 1);
1276        assert_eq!(settings.priority[0].model.as_deref(), Some("low"));
1277    }
1278
1279    #[test]
1280    fn test_save_and_reload() -> TestResult {
1281        let settings = load_sample()?;
1282        let tmp = tempfile::NamedTempFile::new()?;
1283        settings.save(Some(tmp.path()))?;
1284
1285        let content = std::fs::read_to_string(tmp.path())?;
1286        let reloaded: Settings = serde_json::from_str(&content)?;
1287
1288        assert_eq!(reloaded.agents.len(), settings.agents.len());
1289        assert_eq!(reloaded.priority.len(), settings.priority.len());
1290        Ok(())
1291    }
1292
1293    #[test]
1294    fn test_save_preserves_comments() -> TestResult {
1295        let jsonc = r#"{
1296    // This is a top-level comment
1297    "agents": [
1298        {"command": "claude"}
1299    ]
1300}"#;
1301        let tmp = tempfile::NamedTempFile::new()?;
1302        std::fs::write(tmp.path(), jsonc)?;
1303
1304        let settings = Settings::load(Some(tmp.path()))?;
1305        settings.save(Some(tmp.path()))?;
1306
1307        let content = std::fs::read_to_string(tmp.path())?;
1308        assert!(content.contains("// This is a top-level comment"));
1309        assert!(content.contains("claude"));
1310        Ok(())
1311    }
1312
1313    #[test]
1314    fn test_save_plain_json_roundtrip_via_load() -> TestResult {
1315        let json = r#"{"agents": [{"command": "claude"}]}"#;
1316        let tmp = tempfile::NamedTempFile::new()?;
1317        std::fs::write(tmp.path(), json)?;
1318
1319        let settings = Settings::load(Some(tmp.path()))?;
1320        settings.save(Some(tmp.path()))?;
1321
1322        let content = std::fs::read_to_string(tmp.path())?;
1323        let reloaded = Settings::load(Some(tmp.path()))?;
1324        assert_eq!(reloaded.agents.len(), 1);
1325        assert_eq!(reloaded.agents[0].command, "claude");
1326        // Should be valid JSON (parseable)
1327        let _: serde_json::Value = serde_json::from_str(&content)?;
1328        Ok(())
1329    }
1330
1331    #[test]
1332    fn test_save_with_added_agent_preserves_comments() -> TestResult {
1333        let jsonc = r#"{
1334    // Top comment
1335    "agents": [
1336        {"command": "claude"}
1337    ]
1338}"#;
1339        let tmp = tempfile::NamedTempFile::new()?;
1340        std::fs::write(tmp.path(), jsonc)?;
1341
1342        let mut settings = Settings::load(Some(tmp.path()))?;
1343        settings.agents.push(AgentConfig {
1344            command: "codex".to_string(),
1345            args: vec![],
1346            models: None,
1347            arg_maps: HashMap::new(),
1348            env: None,
1349            provider: None,
1350            openrouter_management_key: None,
1351            glm_api_key: None,
1352            pre_command: vec![],
1353        });
1354        settings.save(Some(tmp.path()))?;
1355
1356        let content = std::fs::read_to_string(tmp.path())?;
1357        assert!(content.contains("// Top comment"));
1358        assert!(content.contains("codex"));
1359        Ok(())
1360    }
1361
1362    #[test]
1363    fn test_save_preserves_inline_comments_inside_agents_array() -> TestResult {
1364        let jsonc = r#"{
1365    "agents": [
1366        // Claude agent configuration
1367        {
1368            "command": "claude",
1369            "args": ["--model", "{model}"],
1370            // Model name mapping
1371            "models": {
1372                "high": "opus",
1373                "medium": "sonnet"
1374            }
1375        },
1376        // Codex agent
1377        {"command": "codex"}
1378    ]
1379}"#;
1380        let tmp = tempfile::NamedTempFile::new()?;
1381        std::fs::write(tmp.path(), jsonc)?;
1382
1383        let settings = Settings::load(Some(tmp.path()))?;
1384        settings.save(Some(tmp.path()))?;
1385
1386        let content = std::fs::read_to_string(tmp.path())?;
1387        assert!(
1388            content.contains("// Claude agent configuration"),
1389            "comment before first agent lost:\n{content}"
1390        );
1391        assert!(
1392            content.contains("// Model name mapping"),
1393            "comment inside agent object lost:\n{content}"
1394        );
1395        assert!(
1396            content.contains("// Codex agent"),
1397            "comment before second agent lost:\n{content}"
1398        );
1399        Ok(())
1400    }
1401
1402    #[test]
1403    fn test_save_preserves_comments_after_modifying_agent() -> TestResult {
1404        let jsonc = r#"{
1405    "agents": [
1406        // My main agent
1407        {
1408            "command": "claude",
1409            // important args
1410            "args": ["--model", "{model}"]
1411        }
1412    ]
1413}"#;
1414        let tmp = tempfile::NamedTempFile::new()?;
1415        std::fs::write(tmp.path(), jsonc)?;
1416
1417        let mut settings = Settings::load(Some(tmp.path()))?;
1418        settings.agents[0].command = "opencode".to_string();
1419        settings.save(Some(tmp.path()))?;
1420
1421        let content = std::fs::read_to_string(tmp.path())?;
1422        assert!(
1423            content.contains("// My main agent"),
1424            "comment before agent lost:\n{content}"
1425        );
1426        assert!(
1427            content.contains("// important args"),
1428            "comment inside agent lost:\n{content}"
1429        );
1430        assert!(
1431            content.contains("opencode"),
1432            "command not updated:\n{content}"
1433        );
1434        Ok(())
1435    }
1436
1437    #[test]
1438    fn test_save_preserves_comments_when_removing_agent() -> TestResult {
1439        let jsonc = r#"{
1440    // top-level comment
1441    "agents": [
1442        // first agent
1443        {"command": "claude"},
1444        // second agent
1445        {"command": "codex"}
1446    ]
1447}"#;
1448        let tmp = tempfile::NamedTempFile::new()?;
1449        std::fs::write(tmp.path(), jsonc)?;
1450
1451        let mut settings = Settings::load(Some(tmp.path()))?;
1452        settings.agents.remove(1); // remove codex
1453        settings.save(Some(tmp.path()))?;
1454
1455        let content = std::fs::read_to_string(tmp.path())?;
1456        assert!(
1457            content.contains("// top-level comment"),
1458            "top-level comment lost:\n{content}"
1459        );
1460        assert!(
1461            content.contains("// first agent"),
1462            "first agent comment lost:\n{content}"
1463        );
1464        assert!(
1465            content.contains("claude"),
1466            "claude not preserved:\n{content}"
1467        );
1468        // codex should be gone
1469        assert!(
1470            !content.contains("codex"),
1471            "codex should be removed:\n{content}"
1472        );
1473        Ok(())
1474    }
1475
1476    #[test]
1477    fn test_save_preserves_comments_with_priority_and_agent_change() -> TestResult {
1478        let jsonc = r#"{
1479    // Global priority rules
1480    "priority": [
1481        {"command": "claude", "model": "high", "priority": 50}
1482    ],
1483    // Agent configurations
1484    "agents": [
1485        // Claude Code agent - primary
1486        {
1487            "command": "claude",
1488            "args": ["--model", "{model}"],
1489            // Model name mapping
1490            "models": {
1491                "high": "opus",
1492                "medium": "sonnet"
1493            }
1494        },
1495        // Codex agent - secondary
1496        {"command": "codex"}
1497    ]
1498}"#;
1499        let tmp = tempfile::NamedTempFile::new()?;
1500        std::fs::write(tmp.path(), jsonc)?;
1501
1502        let mut settings = Settings::load(Some(tmp.path()))?;
1503        // Simulate GUI: change agent command and add a priority rule
1504        settings.agents[0].command = "opencode".to_string();
1505        settings.upsert_priority("opencode", None, Some("high".to_string()), 50);
1506        settings.save(Some(tmp.path()))?;
1507
1508        let content = std::fs::read_to_string(tmp.path())?;
1509        assert!(
1510            content.contains("// Global priority rules"),
1511            "top-level priority comment lost:\n{content}"
1512        );
1513        assert!(
1514            content.contains("// Agent configurations"),
1515            "top-level agents comment lost:\n{content}"
1516        );
1517        assert!(
1518            content.contains("// Claude Code agent - primary"),
1519            "comment before first agent lost:\n{content}"
1520        );
1521        assert!(
1522            content.contains("// Model name mapping"),
1523            "comment inside agent object lost:\n{content}"
1524        );
1525        assert!(
1526            content.contains("// Codex agent - secondary"),
1527            "comment before second agent lost:\n{content}"
1528        );
1529        assert!(
1530            content.contains("opencode"),
1531            "command not updated:\n{content}"
1532        );
1533        Ok(())
1534    }
1535
1536    #[test]
1537    fn test_serde_value_to_cst_input_variants() {
1538        use jsonc_parser::cst::CstInputValue;
1539
1540        assert!(matches!(
1541            serde_value_to_cst_input(&serde_json::Value::Null),
1542            CstInputValue::Null
1543        ));
1544        assert!(matches!(
1545            serde_value_to_cst_input(&serde_json::Value::Bool(true)),
1546            CstInputValue::Bool(true)
1547        ));
1548        assert!(matches!(
1549            serde_value_to_cst_input(&serde_json::Value::String("hi".to_string())),
1550            CstInputValue::String(s) if s == "hi"
1551        ));
1552        assert!(matches!(
1553            serde_value_to_cst_input(&serde_json::json!(42)),
1554            CstInputValue::Number(n) if n == "42"
1555        ));
1556        assert!(matches!(
1557            serde_value_to_cst_input(&serde_json::json!([])),
1558            CstInputValue::Array(v) if v.is_empty()
1559        ));
1560        assert!(matches!(
1561            serde_value_to_cst_input(&serde_json::json!({})),
1562            CstInputValue::Object(v) if v.is_empty()
1563        ));
1564    }
1565
1566    #[test]
1567    fn test_command_zai_infers_provider_zai() -> TestResult {
1568        let json = r#"{"agents": [{"command": "zai"}]}"#;
1569        let settings: Settings = serde_json::from_str(json)?;
1570
1571        assert_eq!(settings.agents[0].resolve_provider(), Some("zai"));
1572        assert_eq!(settings.agents[0].resolve_domain(), None);
1573        Ok(())
1574    }
1575
1576    #[test]
1577    fn test_command_kimik2_infers_provider_kimik2() -> TestResult {
1578        let json = r#"{"agents": [{"command": "kimi-k2"}]}"#;
1579        let settings: Settings = serde_json::from_str(json)?;
1580
1581        assert_eq!(settings.agents[0].resolve_provider(), Some("kimi-k2"));
1582        assert_eq!(settings.agents[0].resolve_domain(), None);
1583        Ok(())
1584    }
1585
1586    #[test]
1587    fn test_command_warp_infers_provider_warp() -> TestResult {
1588        let json = r#"{"agents": [{"command": "warp"}]}"#;
1589        let settings: Settings = serde_json::from_str(json)?;
1590
1591        assert_eq!(settings.agents[0].resolve_provider(), Some("warp"));
1592        assert_eq!(settings.agents[0].resolve_domain(), None);
1593        Ok(())
1594    }
1595
1596    #[test]
1597    fn test_command_kiro_infers_provider_kiro() -> TestResult {
1598        let json = r#"{"agents": [{"command": "kiro"}]}"#;
1599        let settings: Settings = serde_json::from_str(json)?;
1600
1601        assert_eq!(settings.agents[0].resolve_provider(), Some("kiro"));
1602        assert_eq!(settings.agents[0].resolve_domain(), None);
1603        Ok(())
1604    }
1605
1606    // -----------------------------------------------------------------------
1607    // helpers for schedule tests
1608    // -----------------------------------------------------------------------
1609
1610    fn make_scheduled_rule(
1611        command: &str,
1612        priority: i32,
1613        weekdays: Option<Vec<&str>>,
1614        hours: Option<Vec<&str>>,
1615    ) -> PriorityRule {
1616        PriorityRule {
1617            command: command.to_string(),
1618            provider: None,
1619            model: None,
1620            priority,
1621            weekdays: weekdays.map(|v| {
1622                v.into_iter()
1623                    .map(std::string::ToString::to_string)
1624                    .collect()
1625            }),
1626            hours: hours.map(|v| {
1627                v.into_iter()
1628                    .map(std::string::ToString::to_string)
1629                    .collect()
1630            }),
1631        }
1632    }
1633
1634    // -----------------------------------------------------------------------
1635    // PriorityRule schedule fields: serde
1636    // -----------------------------------------------------------------------
1637
1638    #[test]
1639    fn test_priority_rule_deserializes_weekdays_and_hours() -> TestResult {
1640        // Given: a priority rule JSON with weekdays and hours
1641        let json = r#"{
1642            "priority": [{
1643                "command": "codex",
1644                "priority": 200,
1645                "weekdays": ["1-5"],
1646                "hours": ["21-27"]
1647            }],
1648            "agents": []
1649        }"#;
1650
1651        // When: parsed
1652        let settings: Settings = serde_json::from_str(json)?;
1653
1654        // Then: fields are populated correctly
1655        assert_eq!(settings.priority[0].weekdays, Some(vec!["1-5".to_string()]));
1656        assert_eq!(settings.priority[0].hours, Some(vec!["21-27".to_string()]));
1657        Ok(())
1658    }
1659
1660    #[test]
1661    fn test_priority_rule_weekdays_hours_absent_defaults_to_none() -> TestResult {
1662        // Given: a priority rule without weekdays/hours
1663        let json = r#"{"priority": [{"command": "codex", "priority": 50}], "agents": []}"#;
1664
1665        // When: parsed
1666        let settings: Settings = serde_json::from_str(json)?;
1667
1668        // Then: optional schedule fields are None
1669        assert_eq!(settings.priority[0].weekdays, None);
1670        assert_eq!(settings.priority[0].hours, None);
1671        Ok(())
1672    }
1673
1674    #[test]
1675    fn test_priority_rule_serializes_without_schedule_fields_when_none() -> TestResult {
1676        // Given: a rule with no schedule fields
1677        let rule = make_scheduled_rule("codex", 50, None, None);
1678        let settings = Settings {
1679            priority: vec![rule],
1680            agents: vec![],
1681            original_text: None,
1682        };
1683
1684        // When: serialized to JSON
1685        let json = serde_json::to_string(&settings)?;
1686        let val: serde_json::Value = serde_json::from_str(&json)?;
1687
1688        // Then: weekdays and hours fields are absent
1689        assert!(
1690            val["priority"][0]["weekdays"].is_null(),
1691            "weekdays should be absent when None"
1692        );
1693        assert!(
1694            val["priority"][0]["hours"].is_null(),
1695            "hours should be absent when None"
1696        );
1697        Ok(())
1698    }
1699
1700    #[test]
1701    fn test_priority_rule_serializes_with_schedule_fields_when_some() -> TestResult {
1702        // Given: a rule with weekdays and hours
1703        let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), Some(vec!["21-27"]));
1704        let settings = Settings {
1705            priority: vec![rule],
1706            agents: vec![],
1707            original_text: None,
1708        };
1709
1710        // When: serialized to JSON
1711        let json = serde_json::to_string(&settings)?;
1712        let val: serde_json::Value = serde_json::from_str(&json)?;
1713
1714        // Then: weekdays and hours are present with correct values
1715        assert_eq!(val["priority"][0]["weekdays"], serde_json::json!(["1-5"]));
1716        assert_eq!(val["priority"][0]["hours"], serde_json::json!(["21-27"]));
1717        Ok(())
1718    }
1719
1720    #[test]
1721    fn test_priority_rule_multiple_hour_ranges_serialize_and_deserialize() -> TestResult {
1722        // Given: multiple hour ranges
1723        let json = r#"{
1724            "priority": [{"command": "codex", "priority": 100, "hours": ["1-7", "21-27"]}],
1725            "agents": []
1726        }"#;
1727
1728        // When: parsed
1729        let settings: Settings = serde_json::from_str(json)?;
1730
1731        // Then: both ranges are preserved
1732        assert_eq!(
1733            settings.priority[0].hours,
1734            Some(vec!["1-7".to_string(), "21-27".to_string()])
1735        );
1736        Ok(())
1737    }
1738
1739    // -----------------------------------------------------------------------
1740    // Range validation on load
1741    // -----------------------------------------------------------------------
1742
1743    #[test]
1744    fn test_load_rejects_hours_range_where_start_equals_end() -> TestResult {
1745        // Given: hours range "7-7" where start == end (invalid: not start < end)
1746        let json = r#"{"priority": [{"command": "codex", "priority": 50, "hours": ["7-7"]}], "agents": []}"#;
1747        let tmp = tempfile::NamedTempFile::new()?;
1748        std::fs::write(tmp.path(), json)?;
1749
1750        // When/Then: load returns an error
1751        let result = Settings::load(Some(tmp.path()));
1752        assert!(
1753            result.is_err(),
1754            "expected load error for hours range where start == end"
1755        );
1756        Ok(())
1757    }
1758
1759    #[test]
1760    fn test_load_rejects_hours_range_where_start_exceeds_end() -> TestResult {
1761        // Given: hours range "10-5" where start > end (invalid)
1762        let json = r#"{"priority": [{"command": "codex", "priority": 50, "hours": ["10-5"]}], "agents": []}"#;
1763        let tmp = tempfile::NamedTempFile::new()?;
1764        std::fs::write(tmp.path(), json)?;
1765
1766        // When/Then: load returns an error
1767        let result = Settings::load(Some(tmp.path()));
1768        assert!(
1769            result.is_err(),
1770            "expected load error for hours range where start > end"
1771        );
1772        Ok(())
1773    }
1774
1775    #[test]
1776    fn test_load_rejects_hours_end_exceeds_48() -> TestResult {
1777        // Given: hours end value 49 which exceeds the maximum of 48
1778        let json = r#"{"priority": [{"command": "codex", "priority": 50, "hours": ["20-49"]}], "agents": []}"#;
1779        let tmp = tempfile::NamedTempFile::new()?;
1780        std::fs::write(tmp.path(), json)?;
1781
1782        // When/Then: load returns an error
1783        let result = Settings::load(Some(tmp.path()));
1784        assert!(result.is_err(), "expected load error for hours end > 48");
1785        Ok(())
1786    }
1787
1788    #[test]
1789    fn test_load_rejects_weekday_range_where_start_exceeds_end() -> TestResult {
1790        // Given: weekdays range "5-2" where start > end
1791        let json = r#"{"priority": [{"command": "codex", "priority": 50, "weekdays": ["5-2"]}], "agents": []}"#;
1792        let tmp = tempfile::NamedTempFile::new()?;
1793        std::fs::write(tmp.path(), json)?;
1794
1795        // When/Then: load returns an error
1796        let result = Settings::load(Some(tmp.path()));
1797        assert!(
1798            result.is_err(),
1799            "expected load error for weekdays range where start > end"
1800        );
1801        Ok(())
1802    }
1803
1804    #[test]
1805    fn test_load_rejects_weekday_value_exceeds_6() -> TestResult {
1806        // Given: weekdays range with end value 7 which is beyond Saturday (6)
1807        let json = r#"{"priority": [{"command": "codex", "priority": 50, "weekdays": ["1-7"]}], "agents": []}"#;
1808        let tmp = tempfile::NamedTempFile::new()?;
1809        std::fs::write(tmp.path(), json)?;
1810
1811        // When/Then: load returns an error
1812        let result = Settings::load(Some(tmp.path()));
1813        assert!(result.is_err(), "expected load error for weekday value > 6");
1814        Ok(())
1815    }
1816
1817    #[test]
1818    fn test_load_accepts_valid_hour_range_0_to_24() -> TestResult {
1819        // Given: valid hours range "0-24"
1820        let json = r#"{"priority": [{"command": "codex", "priority": 50, "hours": ["0-24"]}], "agents": []}"#;
1821        let tmp = tempfile::NamedTempFile::new()?;
1822        std::fs::write(tmp.path(), json)?;
1823
1824        // When/Then: load succeeds
1825        let result = Settings::load(Some(tmp.path()));
1826        assert!(
1827            result.is_ok(),
1828            "expected load to succeed for valid hour range"
1829        );
1830        Ok(())
1831    }
1832
1833    #[test]
1834    fn test_load_accepts_valid_weekday_range_1_to_5() -> TestResult {
1835        // Given: valid weekdays range "1-5" (Mon-Fri)
1836        let json = r#"{"priority": [{"command": "codex", "priority": 50, "weekdays": ["1-5"]}], "agents": []}"#;
1837        let tmp = tempfile::NamedTempFile::new()?;
1838        std::fs::write(tmp.path(), json)?;
1839
1840        // When/Then: load succeeds
1841        let result = Settings::load(Some(tmp.path()));
1842        assert!(
1843            result.is_ok(),
1844            "expected load to succeed for valid weekday range"
1845        );
1846        Ok(())
1847    }
1848
1849    #[test]
1850    fn test_load_accepts_overnight_hour_range_21_to_27() -> TestResult {
1851        // Given: valid overnight hours range "21-27"
1852        let json = r#"{"priority": [{"command": "codex", "priority": 200, "hours": ["21-27"]}], "agents": []}"#;
1853        let tmp = tempfile::NamedTempFile::new()?;
1854        std::fs::write(tmp.path(), json)?;
1855
1856        // When/Then: load succeeds
1857        let result = Settings::load(Some(tmp.path()));
1858        assert!(
1859            result.is_ok(),
1860            "expected load to succeed for overnight hour range"
1861        );
1862        Ok(())
1863    }
1864
1865    // -----------------------------------------------------------------------
1866    // PriorityRule::matches_at
1867    // -----------------------------------------------------------------------
1868
1869    #[test]
1870    fn test_matches_at_no_schedule_always_matches_any_time() {
1871        // Given: base rule with no weekdays/hours
1872        let rule = make_scheduled_rule("codex", 50, None, None);
1873        let monday_22h = make_local_dt(2024, 1, 8, 22);
1874        let saturday_3h = make_local_dt(2024, 1, 13, 3);
1875
1876        // When/Then: matches at any time
1877        assert!(rule.matches_at("codex", Some("codex"), None, &monday_22h));
1878        assert!(rule.matches_at("codex", Some("codex"), None, &saturday_3h));
1879    }
1880
1881    #[test]
1882    fn test_matches_at_no_schedule_wrong_command_returns_false() {
1883        // Given: rule for "codex" with no schedule
1884        let rule = make_scheduled_rule("codex", 50, None, None);
1885        let monday_22h = make_local_dt(2024, 1, 8, 22);
1886
1887        // When/Then: a different command does not match
1888        assert!(!rule.matches_at("claude", Some("claude"), None, &monday_22h));
1889    }
1890
1891    #[test]
1892    fn test_matches_at_weekdays_matches_on_specified_weekday() {
1893        // Given: rule restricted to Mon-Fri (weekdays "1-5")
1894        let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), None);
1895        // 2024-01-08 is Monday (weekday 1)
1896        let monday = make_local_dt(2024, 1, 8, 10);
1897
1898        // When/Then: Monday is within "1-5"
1899        assert!(rule.matches_at("codex", Some("codex"), None, &monday));
1900    }
1901
1902    #[test]
1903    fn test_matches_at_weekdays_no_match_on_off_day() {
1904        // Given: rule restricted to Mon-Fri (weekdays "1-5")
1905        let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), None);
1906        // 2024-01-06 is Saturday (weekday 6)
1907        let saturday = make_local_dt(2024, 1, 6, 10);
1908
1909        // When/Then: Saturday is NOT within "1-5"
1910        assert!(!rule.matches_at("codex", Some("codex"), None, &saturday));
1911    }
1912
1913    #[test]
1914    fn test_matches_at_weekdays_sunday_included_in_0_to_0_range() {
1915        // Given: rule restricted to only Sunday (weekdays "0-0")
1916        let rule = make_scheduled_rule("codex", 200, Some(vec!["0-0"]), None);
1917        // 2024-01-07 is Sunday (weekday 0)
1918        let sunday = make_local_dt(2024, 1, 7, 10);
1919        let monday = make_local_dt(2024, 1, 8, 10);
1920
1921        // When/Then
1922        assert!(rule.matches_at("codex", Some("codex"), None, &sunday));
1923        assert!(!rule.matches_at("codex", Some("codex"), None, &monday));
1924    }
1925
1926    #[test]
1927    fn test_matches_at_hours_matches_within_range() {
1928        // Given: rule restricted to hours "21-27" (21:00-03:00)
1929        let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-27"]));
1930        let monday_22h = make_local_dt(2024, 1, 8, 22);
1931
1932        // When/Then: 22:00 is within [21, 27)
1933        assert!(rule.matches_at("codex", Some("codex"), None, &monday_22h));
1934    }
1935
1936    #[test]
1937    fn test_matches_at_hours_no_match_outside_range() {
1938        // Given: rule restricted to hours "21-27"
1939        let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-27"]));
1940        let monday_20h = make_local_dt(2024, 1, 8, 20);
1941
1942        // When/Then: 20:00 is NOT in [21, 27)
1943        assert!(!rule.matches_at("codex", Some("codex"), None, &monday_20h));
1944    }
1945
1946    #[test]
1947    fn test_matches_at_hours_half_open_end_boundary_does_not_match() {
1948        // Given: rule restricted to hours "21-24" (21:00 to midnight, half-open)
1949        let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-24"]));
1950        // At exactly hour 24 (= next day 00:00), direct match: 24 not in [21, 24)
1951        // Cross-midnight: 24+0=24, not in [21, 24) either
1952        let tuesday_0h = make_local_dt(2024, 1, 9, 0);
1953
1954        // When/Then: midnight (hour 0) is NOT matched by direct or cross-midnight path
1955        // Cross-midnight: 24+0=24, half-open [21, 24) excludes 24
1956        assert!(!rule.matches_at("codex", Some("codex"), None, &tuesday_0h));
1957    }
1958
1959    #[test]
1960    fn test_matches_at_hours_multiple_ranges_uses_or_logic() {
1961        // Given: rule with two separate hour ranges "1-7" and "21-27"
1962        let rule = make_scheduled_rule("codex", 200, None, Some(vec!["1-7", "21-27"]));
1963        let monday_3h = make_local_dt(2024, 1, 8, 3);
1964        let monday_22h = make_local_dt(2024, 1, 8, 22);
1965        let monday_12h = make_local_dt(2024, 1, 8, 12);
1966
1967        // When/Then: both ranges match, middle of day does not
1968        assert!(rule.matches_at("codex", Some("codex"), None, &monday_3h));
1969        assert!(rule.matches_at("codex", Some("codex"), None, &monday_22h));
1970        assert!(!rule.matches_at("codex", Some("codex"), None, &monday_12h));
1971    }
1972
1973    #[test]
1974    fn test_matches_at_overnight_range_matches_hours_in_next_day_morning() {
1975        // Given: rule with hours "21-27" (21:00 Mon to 03:00 Tue)
1976        let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-27"]));
1977        // 2024-01-09 02:00 is Tuesday 02:00
1978        // Cross-midnight check: previous day's hour = 24+2 = 26, which is in [21, 27)
1979        let tuesday_2h = make_local_dt(2024, 1, 9, 2);
1980
1981        // When/Then: Tuesday 02:00 matches because 24+2=26 is in [21, 27)
1982        assert!(rule.matches_at("codex", Some("codex"), None, &tuesday_2h));
1983    }
1984
1985    #[test]
1986    fn test_matches_at_overnight_range_does_not_match_at_boundary_end() {
1987        // Given: rule with hours "21-27"
1988        let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-27"]));
1989        // 2024-01-09 03:00: cross-midnight check gives 24+3=27, which is NOT in [21, 27)
1990        let tuesday_3h = make_local_dt(2024, 1, 9, 3);
1991
1992        // When/Then: Tuesday 03:00 does NOT match (27 excluded by half-open interval)
1993        assert!(!rule.matches_at("codex", Some("codex"), None, &tuesday_3h));
1994    }
1995
1996    #[test]
1997    fn test_matches_at_overnight_with_weekday_uses_start_day_for_cross_midnight_hour() {
1998        // Given: rule for Mon-Fri with hours "21-27"
1999        // Monday 21:00 to Tuesday 03:00 should be active
2000        let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), Some(vec!["21-27"]));
2001        // Tuesday 02:00: cross-midnight → previous day is Monday (weekday 1), which is in "1-5"
2002        let tuesday_2h = make_local_dt(2024, 1, 9, 2);
2003
2004        // When/Then: Tuesday 02:00 matches because it falls within Monday's 21-27 window
2005        assert!(rule.matches_at("codex", Some("codex"), None, &tuesday_2h));
2006    }
2007
2008    #[test]
2009    fn test_matches_at_overnight_with_weekday_no_match_when_start_day_excluded() {
2010        // Given: rule for Mon-Fri with hours "21-27"
2011        // Saturday 21:00 to Sunday 03:00 should NOT be active (Saturday is not in 1-5)
2012        let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), Some(vec!["21-27"]));
2013        // Sunday 02:00: cross-midnight → previous day is Saturday (weekday 6), not in "1-5"
2014        let sunday_2h = make_local_dt(2024, 1, 7, 2);
2015
2016        // When/Then: does not match
2017        assert!(!rule.matches_at("codex", Some("codex"), None, &sunday_2h));
2018    }
2019
2020    #[test]
2021    fn test_matches_at_weekdays_and_hours_both_required_for_match() {
2022        // Given: rule for Mon-Fri with hours "9-17"
2023        let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), Some(vec!["9-17"]));
2024        let monday_10h = make_local_dt(2024, 1, 8, 10); // Mon 10:00 → matches
2025        let monday_20h = make_local_dt(2024, 1, 8, 20); // Mon 20:00 → wrong hour
2026        let saturday_10h = make_local_dt(2024, 1, 13, 10); // Sat 10:00 → wrong day
2027
2028        // When/Then
2029        assert!(rule.matches_at("codex", Some("codex"), None, &monday_10h));
2030        assert!(!rule.matches_at("codex", Some("codex"), None, &monday_20h));
2031        assert!(!rule.matches_at("codex", Some("codex"), None, &saturday_10h));
2032    }
2033
2034    // -----------------------------------------------------------------------
2035    // Settings::priority_for_at
2036    // -----------------------------------------------------------------------
2037
2038    #[test]
2039    fn test_priority_for_at_backward_compat_no_schedule_behaves_like_priority_for() -> TestResult {
2040        // Given: rules without schedule fields (same as existing tests)
2041        let json = r#"{
2042            "priority": [
2043                {"command": "claude", "model": "high", "priority": 42}
2044            ],
2045            "agents": [{"command": "claude"}]
2046        }"#;
2047        let settings: Settings = serde_json::from_str(json)?;
2048        let now = make_local_dt(2024, 1, 8, 10);
2049
2050        // When: calling priority_for_at
2051        let result =
2052            settings.priority_for_components_at("claude", Some("claude"), Some("high"), &now);
2053
2054        // Then: same result as the original priority_for_components
2055        assert_eq!(result, 42);
2056        Ok(())
2057    }
2058
2059    #[test]
2060    fn test_priority_for_at_returns_zero_when_no_rule_matches() -> TestResult {
2061        // Given: rule for different command
2062        let json = r#"{"priority": [{"command": "codex", "priority": 50}], "agents": []}"#;
2063        let settings: Settings = serde_json::from_str(json)?;
2064        let now = make_local_dt(2024, 1, 8, 10);
2065
2066        // When/Then: no matching rule → 0
2067        assert_eq!(
2068            settings.priority_for_components_at("claude", Some("claude"), None, &now),
2069            0
2070        );
2071        Ok(())
2072    }
2073
2074    #[test]
2075    fn test_priority_for_at_scheduled_rule_overrides_base_when_active() -> TestResult {
2076        // Given: base rule (priority 50) and a higher-priority scheduled rule for Mon-Fri 21-27
2077        let json = r#"{
2078            "priority": [
2079                {"command": "codex", "priority": 50},
2080                {"command": "codex", "priority": 200, "weekdays": ["1-5"], "hours": ["21-27"]}
2081            ],
2082            "agents": []
2083        }"#;
2084        let settings: Settings = serde_json::from_str(json)?;
2085        // 2024-01-08 22:00 = Monday 22:00 → active window
2086        let active_time = make_local_dt(2024, 1, 8, 22);
2087
2088        // When: evaluating during active window
2089        let result =
2090            settings.priority_for_components_at("codex", Some("codex"), None, &active_time);
2091
2092        // Then: scheduled rule (200) overrides base rule (50)
2093        assert_eq!(result, 200);
2094        Ok(())
2095    }
2096
2097    #[test]
2098    fn test_priority_for_at_base_rule_active_when_scheduled_is_inactive() -> TestResult {
2099        // Given: same rules as above
2100        let json = r#"{
2101            "priority": [
2102                {"command": "codex", "priority": 50},
2103                {"command": "codex", "priority": 200, "weekdays": ["1-5"], "hours": ["21-27"]}
2104            ],
2105            "agents": []
2106        }"#;
2107        let settings: Settings = serde_json::from_str(json)?;
2108        // 2024-01-13 22:00 = Saturday 22:00 → outside Mon-Fri window
2109        let inactive_time = make_local_dt(2024, 1, 13, 22);
2110
2111        // When: evaluating outside active window
2112        let result =
2113            settings.priority_for_components_at("codex", Some("codex"), None, &inactive_time);
2114
2115        // Then: base rule (50) is used
2116        assert_eq!(result, 50);
2117        Ok(())
2118    }
2119
2120    #[test]
2121    fn test_priority_for_at_most_specific_rule_wins_over_less_specific() -> TestResult {
2122        // Given: three rules with different specificity for the same target
2123        // - base rule (no schedule): priority 10
2124        // - hours-only rule: priority 30
2125        // - weekdays+hours rule: priority 50
2126        let json = r#"{
2127            "priority": [
2128                {"command": "codex", "priority": 10},
2129                {"command": "codex", "priority": 30, "hours": ["21-27"]},
2130                {"command": "codex", "priority": 50, "weekdays": ["1-5"], "hours": ["21-27"]}
2131            ],
2132            "agents": []
2133        }"#;
2134        let settings: Settings = serde_json::from_str(json)?;
2135        // Monday 22:00 → all three rules match
2136        let monday_22h = make_local_dt(2024, 1, 8, 22);
2137
2138        // When: evaluating during the window all three match
2139        let result = settings.priority_for_components_at("codex", Some("codex"), None, &monday_22h);
2140
2141        // Then: most specific rule (weekdays+hours, priority 50) wins
2142        assert_eq!(result, 50);
2143        Ok(())
2144    }
2145
2146    #[test]
2147    fn test_priority_for_at_same_specificity_first_rule_wins() -> TestResult {
2148        // Given: two rules with same specificity (both hours-only), different priorities
2149        let json = r#"{
2150            "priority": [
2151                {"command": "codex", "priority": 30, "hours": ["20-23"]},
2152                {"command": "codex", "priority": 99, "hours": ["21-27"]}
2153            ],
2154            "agents": []
2155        }"#;
2156        let settings: Settings = serde_json::from_str(json)?;
2157        // 22:00 matches both "20-23" and "21-27"
2158        let time_22h = make_local_dt(2024, 1, 8, 22);
2159
2160        // When: both match with equal specificity
2161        let result = settings.priority_for_components_at("codex", Some("codex"), None, &time_22h);
2162
2163        // Then: first rule wins (stable, order-compatible)
2164        assert_eq!(result, 30);
2165        Ok(())
2166    }
2167
2168    #[test]
2169    fn test_priority_for_at_hours_only_rule_matches_when_weekday_inactive() -> TestResult {
2170        // Given: base rule and a hours-only rule (no weekday restriction)
2171        let json = r#"{
2172            "priority": [
2173                {"command": "codex", "priority": 10},
2174                {"command": "codex", "priority": 80, "hours": ["21-27"]}
2175            ],
2176            "agents": []
2177        }"#;
2178        let settings: Settings = serde_json::from_str(json)?;
2179        // Saturday 22:00 → hours-only rule applies (no weekday restriction)
2180        let saturday_22h = make_local_dt(2024, 1, 13, 22);
2181
2182        // When/Then: hours-only rule (80) wins over base rule (10)
2183        let result =
2184            settings.priority_for_components_at("codex", Some("codex"), None, &saturday_22h);
2185        assert_eq!(result, 80);
2186        Ok(())
2187    }
2188
2189    // -----------------------------------------------------------------------
2190    // Save with schedule fields: JSONC comment preservation
2191    // -----------------------------------------------------------------------
2192
2193    #[test]
2194    fn test_save_preserves_comments_with_weekdays_and_hours_fields() -> TestResult {
2195        // Given: a JSONC file with comments and a scheduled priority rule
2196        let jsonc = r#"{
2197    // Scheduled priority overrides
2198    "priority": [
2199        // Base rule
2200        {"command": "codex", "priority": 50},
2201        // Nighttime boost
2202        {"command": "codex", "priority": 200, "weekdays": ["1-5"], "hours": ["21-27"]}
2203    ],
2204    "agents": [{"command": "codex"}]
2205}"#;
2206        let tmp = tempfile::NamedTempFile::new()?;
2207        std::fs::write(tmp.path(), jsonc)?;
2208
2209        let mut settings = Settings::load(Some(tmp.path()))?;
2210        // Modify priority to trigger CST save
2211        settings.priority[0].priority = 60;
2212        settings.save(Some(tmp.path()))?;
2213
2214        let content = std::fs::read_to_string(tmp.path())?;
2215
2216        // Then: comments are preserved
2217        assert!(
2218            content.contains("// Scheduled priority overrides"),
2219            "top-level comment lost:\n{content}"
2220        );
2221        assert!(
2222            content.contains("// Base rule"),
2223            "base rule comment lost:\n{content}"
2224        );
2225        assert!(
2226            content.contains("// Nighttime boost"),
2227            "scheduled rule comment lost:\n{content}"
2228        );
2229        // And the updated priority value is present
2230        assert!(
2231            content.contains("60"),
2232            "updated priority value missing:\n{content}"
2233        );
2234        // And the schedule fields are preserved
2235        assert!(content.contains("21-27"), "hours field lost:\n{content}");
2236        assert!(content.contains("1-5"), "weekdays field lost:\n{content}");
2237        Ok(())
2238    }
2239}