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#[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 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub weekdays: Option<Vec<String>>,
132 #[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 #[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(); 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 if current_hour >= start && current_hour < end {
228 weekday_in_ranges(current_weekday, self.weekdays.as_deref())
229 }
230 else if end > 24 {
232 let shifted = current_hour + 24;
233 if shifted >= start && shifted < end {
234 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 return false;
254 }
255
256 true
257 }
258
259 #[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 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 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 for (i, new_val) in new_arr.iter().enumerate().take(existing_len) {
410 merge_cst_node(&elements[i], new_val);
411 }
412
413 for element in elements.into_iter().skip(new_len).rev() {
415 element.remove();
416 }
417
418 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 } else {
469 result.push(c);
470 }
471 } else {
472 result.push(c);
473 }
474
475 i += 1;
476 }
477
478 result
479}
480
481fn 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
491fn 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 #[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 #[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 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 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 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 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 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#[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 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 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 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 let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
1127
1128 let settings: Settings = serde_json::from_str(json)?;
1130
1131 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 let json = r#"{"agents": [{"command": "claude"}]}"#;
1143
1144 let settings: Settings = serde_json::from_str(json)?;
1146
1147 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 let json = r#"{"agents": [{"command": "myai", "provider": "openrouter", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
1156
1157 let settings: Settings = serde_json::from_str(json)?;
1159
1160 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 let json = r#"{"agents": [{"command": "claude", "openrouter_management_key": "sk-or-v1-abc123"}]}"#;
1190
1191 let settings: Settings = serde_json::from_str(json)?;
1193
1194 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 #[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 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 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); 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 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 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 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 #[test]
1639 fn test_priority_rule_deserializes_weekdays_and_hours() -> TestResult {
1640 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 let settings: Settings = serde_json::from_str(json)?;
1653
1654 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 let json = r#"{"priority": [{"command": "codex", "priority": 50}], "agents": []}"#;
1664
1665 let settings: Settings = serde_json::from_str(json)?;
1667
1668 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 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 let json = serde_json::to_string(&settings)?;
1686 let val: serde_json::Value = serde_json::from_str(&json)?;
1687
1688 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 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 let json = serde_json::to_string(&settings)?;
1712 let val: serde_json::Value = serde_json::from_str(&json)?;
1713
1714 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 let json = r#"{
1724 "priority": [{"command": "codex", "priority": 100, "hours": ["1-7", "21-27"]}],
1725 "agents": []
1726 }"#;
1727
1728 let settings: Settings = serde_json::from_str(json)?;
1730
1731 assert_eq!(
1733 settings.priority[0].hours,
1734 Some(vec!["1-7".to_string(), "21-27".to_string()])
1735 );
1736 Ok(())
1737 }
1738
1739 #[test]
1744 fn test_load_rejects_hours_range_where_start_equals_end() -> TestResult {
1745 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
1870 fn test_matches_at_no_schedule_always_matches_any_time() {
1871 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 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 let rule = make_scheduled_rule("codex", 50, None, None);
1885 let monday_22h = make_local_dt(2024, 1, 8, 22);
1886
1887 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 let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), None);
1895 let monday = make_local_dt(2024, 1, 8, 10);
1897
1898 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 let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), None);
1906 let saturday = make_local_dt(2024, 1, 6, 10);
1908
1909 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 let rule = make_scheduled_rule("codex", 200, Some(vec!["0-0"]), None);
1917 let sunday = make_local_dt(2024, 1, 7, 10);
1919 let monday = make_local_dt(2024, 1, 8, 10);
1920
1921 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 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 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 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 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 let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-24"]));
1950 let tuesday_0h = make_local_dt(2024, 1, 9, 0);
1953
1954 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 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 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 let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-27"]));
1977 let tuesday_2h = make_local_dt(2024, 1, 9, 2);
1980
1981 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 let rule = make_scheduled_rule("codex", 200, None, Some(vec!["21-27"]));
1989 let tuesday_3h = make_local_dt(2024, 1, 9, 3);
1991
1992 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 let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), Some(vec!["21-27"]));
2001 let tuesday_2h = make_local_dt(2024, 1, 9, 2);
2003
2004 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 let rule = make_scheduled_rule("codex", 200, Some(vec!["1-5"]), Some(vec!["21-27"]));
2013 let sunday_2h = make_local_dt(2024, 1, 7, 2);
2015
2016 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 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); let monday_20h = make_local_dt(2024, 1, 8, 20); let saturday_10h = make_local_dt(2024, 1, 13, 10); 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 #[test]
2039 fn test_priority_for_at_backward_compat_no_schedule_behaves_like_priority_for() -> TestResult {
2040 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 let result =
2052 settings.priority_for_components_at("claude", Some("claude"), Some("high"), &now);
2053
2054 assert_eq!(result, 42);
2056 Ok(())
2057 }
2058
2059 #[test]
2060 fn test_priority_for_at_returns_zero_when_no_rule_matches() -> TestResult {
2061 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 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 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 let active_time = make_local_dt(2024, 1, 8, 22);
2087
2088 let result =
2090 settings.priority_for_components_at("codex", Some("codex"), None, &active_time);
2091
2092 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 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 let inactive_time = make_local_dt(2024, 1, 13, 22);
2110
2111 let result =
2113 settings.priority_for_components_at("codex", Some("codex"), None, &inactive_time);
2114
2115 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 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 let monday_22h = make_local_dt(2024, 1, 8, 22);
2137
2138 let result = settings.priority_for_components_at("codex", Some("codex"), None, &monday_22h);
2140
2141 assert_eq!(result, 50);
2143 Ok(())
2144 }
2145
2146 #[test]
2147 fn test_priority_for_at_same_specificity_first_rule_wins() -> TestResult {
2148 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 let time_22h = make_local_dt(2024, 1, 8, 22);
2159
2160 let result = settings.priority_for_components_at("codex", Some("codex"), None, &time_22h);
2162
2163 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 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 let saturday_22h = make_local_dt(2024, 1, 13, 22);
2181
2182 let result =
2184 settings.priority_for_components_at("codex", Some("codex"), None, &saturday_22h);
2185 assert_eq!(result, 80);
2186 Ok(())
2187 }
2188
2189 #[test]
2194 fn test_save_preserves_comments_with_weekdays_and_hours_fields() -> TestResult {
2195 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 settings.priority[0].priority = 60;
2212 settings.save(Some(tmp.path()))?;
2213
2214 let content = std::fs::read_to_string(tmp.path())?;
2215
2216 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 assert!(
2231 content.contains("60"),
2232 "updated priority value missing:\n{content}"
2233 );
2234 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}