1use schemars::JsonSchema;
5use serde::Deserialize;
6use std::collections::BTreeMap;
7use std::path::{Path, PathBuf};
8use std::str::FromStr;
9
10#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
18#[serde(default)]
19#[schemars(extend("additionalProperties" = false))]
20pub struct Config {
21 pub line: Option<LineConfig>,
22 pub theme: Option<String>,
23 pub layout: LayoutMode,
28 pub layout_options: Option<LayoutOptions>,
29 #[serde(default)]
30 pub segments: BTreeMap<String, SegmentOverride>,
31 #[serde(default)]
36 pub plugin_dirs: Vec<PathBuf>,
37 pub preset: Option<String>,
42 #[serde(default)]
50 #[schemars(with = "Option<BTreeMap<String, serde_json::Value>>")]
51 pub plugins: Option<BTreeMap<String, toml::Value>>,
52 #[serde(default, rename = "$schema")]
59 pub schema_url: Option<String>,
60}
61
62#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
65#[serde(default)]
66#[non_exhaustive]
67#[schemars(extend("additionalProperties" = false))]
68pub struct LayoutOptions {
69 pub color: ColorPolicy,
70 pub claude_padding: u16,
71 pub separator: Option<String>,
79 pub powerline_width: Option<u16>,
85}
86
87#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
91#[serde(rename_all = "lowercase")]
92#[non_exhaustive]
93pub enum ColorPolicy {
94 #[default]
95 Auto,
96 Always,
97 Never,
98}
99
100#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
112#[serde(default)]
113pub struct LineConfig {
114 pub segments: Vec<String>,
115 #[serde(flatten)]
125 #[schemars(with = "serde_json::Value")]
126 pub numbered: BTreeMap<String, toml::Value>,
127}
128
129#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
133#[serde(rename_all = "kebab-case")]
134#[non_exhaustive]
135pub enum LayoutMode {
136 #[default]
137 SingleLine,
138 MultiLine,
139}
140
141#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
152#[serde(default)]
153pub struct SegmentOverride {
154 pub priority: Option<u8>,
155 pub width: Option<WidthBoundsConfig>,
156 pub style: Option<String>,
157 #[serde(flatten)]
167 #[schemars(with = "serde_json::Value")]
168 pub extra: BTreeMap<String, toml::Value>,
169}
170
171pub const SCHEMA_URL: &str =
181 "https://raw.githubusercontent.com/oakoss/linesmith/main/config.schema.json";
182
183pub fn with_schema_directive(body: &str) -> String {
187 format!("#:schema {SCHEMA_URL}\n\n{body}")
188}
189
190#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
193#[schemars(extend("additionalProperties" = false))]
194pub struct WidthBoundsConfig {
195 pub min: Option<u16>,
196 pub max: Option<u16>,
197}
198
199#[derive(Debug)]
202#[non_exhaustive]
203pub enum ConfigError {
204 Io {
207 path: PathBuf,
208 source: std::io::Error,
209 },
210 Parse {
212 path: Option<PathBuf>,
213 source: toml::de::Error,
214 },
215}
216
217impl std::fmt::Display for ConfigError {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 match self {
220 Self::Io { path, source } => write!(f, "config I/O at {}: {source}", path.display()),
221 Self::Parse {
222 path: Some(p),
223 source,
224 } => write!(f, "config parse at {}: {source}", p.display()),
225 Self::Parse { path: None, source } => write!(f, "config parse: {source}"),
226 }
227 }
228}
229
230impl std::error::Error for ConfigError {
231 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
232 match self {
233 Self::Io { source, .. } => Some(source),
234 Self::Parse { source, .. } => Some(source),
235 }
236 }
237}
238
239impl FromStr for Config {
240 type Err = ConfigError;
241
242 fn from_str(s: &str) -> Result<Self, Self::Err> {
243 toml::from_str(s).map_err(|source| ConfigError::Parse { path: None, source })
244 }
245}
246
247impl Config {
248 pub fn load(path: &Path) -> Result<Option<Self>, ConfigError> {
254 let raw = match std::fs::read_to_string(path) {
255 Ok(s) => s,
256 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
257 Err(source) => {
258 return Err(ConfigError::Io {
259 path: path.to_owned(),
260 source,
261 })
262 }
263 };
264 toml::from_str(&raw)
265 .map(Some)
266 .map_err(|source| ConfigError::Parse {
267 path: Some(path.to_owned()),
268 source,
269 })
270 }
271
272 pub fn load_validated(
278 path: &Path,
279 warn: impl FnMut(&str),
280 ) -> Result<Option<Self>, ConfigError> {
281 let raw = match std::fs::read_to_string(path) {
282 Ok(s) => s,
283 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
284 Err(source) => {
285 return Err(ConfigError::Io {
286 path: path.to_owned(),
287 source,
288 })
289 }
290 };
291 Self::from_str_validated_impl(&raw, Some(path), warn).map(Some)
292 }
293
294 pub fn from_str_validated(s: &str, warn: impl FnMut(&str)) -> Result<Self, ConfigError> {
299 Self::from_str_validated_impl(s, None, warn)
300 }
301
302 fn from_str_validated_impl(
303 s: &str,
304 path: Option<&Path>,
305 mut warn: impl FnMut(&str),
306 ) -> Result<Self, ConfigError> {
307 let raw: toml::Value = toml::from_str(s).map_err(|source| ConfigError::Parse {
308 path: path.map(Path::to_owned),
309 source,
310 })?;
311 validate_keys(&raw, &mut warn);
312 raw.try_into()
313 .map_err(|source: toml::de::Error| ConfigError::Parse {
314 path: path.map(Path::to_owned),
315 source,
316 })
317 }
318}
319
320const KNOWN_TOP_LEVEL: &[&str] = &[
325 "line",
326 "theme",
327 "layout_options",
328 "segments",
329 "plugin_dirs",
330 "preset",
331 "layout",
332 "plugins",
333 "$schema",
334];
335
336const KNOWN_LAYOUT_OPTIONS: &[&str] = &["color", "claude_padding", "separator", "powerline_width"];
339
340fn segment_override_schema(id: &str) -> Option<&'static [&'static str]> {
346 const BUILT_IN_COMMON: &[&str] = &["priority", "width", "style", "visible_if"];
347 const RATE_LIMIT_COMMON: &[&str] = &[
348 "priority",
349 "width",
350 "style",
351 "visible_if",
352 "icon",
353 "label",
354 "stale_marker",
355 "progress_width",
356 "format",
357 ];
358 const PERCENT_SEGMENT: &[&str] = &[
359 "priority",
360 "width",
361 "style",
362 "visible_if",
363 "icon",
364 "label",
365 "stale_marker",
366 "progress_width",
367 "format",
368 "invert",
369 ];
370 const RESET_SEGMENT: &[&str] = &[
371 "priority",
372 "width",
373 "style",
374 "visible_if",
375 "icon",
376 "label",
377 "stale_marker",
378 "progress_width",
379 "format",
380 "compact",
381 "use_days",
382 ];
383 const GIT_BRANCH_SEGMENT: &[&str] = &[
388 "priority",
389 "width",
390 "style",
391 "visible_if",
392 "icon",
393 "label",
394 "max_length",
395 "truncation_marker",
396 "short_sha_length",
397 "dirty",
398 "ahead_behind",
399 ];
400 const MODEL_SEGMENT: &[&str] = &["priority", "width", "style", "visible_if", "format"];
401 match id {
402 "model" => Some(MODEL_SEGMENT),
403 "workspace" | "cost" | "effort" | "context_window" => Some(BUILT_IN_COMMON),
404 "rate_limit_5h" | "rate_limit_7d" => Some(PERCENT_SEGMENT),
405 "rate_limit_5h_reset" | "rate_limit_7d_reset" => Some(RESET_SEGMENT),
406 "extra_usage" => Some(RATE_LIMIT_COMMON),
407 "git_branch" => Some(GIT_BRANCH_SEGMENT),
408 _ => None,
409 }
410}
411
412fn validate_keys(raw: &toml::Value, warn: &mut impl FnMut(&str)) {
418 let Some(top) = raw.as_table() else {
419 return;
420 };
421 for (key, value) in top {
422 if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
423 warn(&format!("unknown top-level config key '{key}'; ignoring"));
424 continue;
425 }
426 match key.as_str() {
427 "layout_options" => {
428 validate_flat_table(value, "layout_options", KNOWN_LAYOUT_OPTIONS, warn)
429 }
430 "segments" => validate_segments_table(value, warn),
431 _ => {}
432 }
433 }
434}
435
436fn validate_flat_table(
437 value: &toml::Value,
438 label: &str,
439 allowed: &[&str],
440 warn: &mut impl FnMut(&str),
441) {
442 let Some(table) = value.as_table() else {
443 return;
444 };
445 for key in table.keys() {
446 if !allowed.contains(&key.as_str()) {
447 warn(&format!("unknown key '{key}' in [{label}]; ignoring"));
448 }
449 }
450}
451
452fn validate_segments_table(value: &toml::Value, warn: &mut impl FnMut(&str)) {
453 let Some(segments) = value.as_table() else {
454 return;
455 };
456 for (id, block) in segments {
457 let Some(block_table) = block.as_table() else {
458 continue;
459 };
460 let Some(allowed) = segment_override_schema(id) else {
461 continue;
465 };
466 for key in block_table.keys() {
467 if !allowed.contains(&key.as_str()) {
468 warn(&format!("unknown key '{key}' in [segments.{id}]; ignoring"));
469 }
470 }
471 }
472}
473
474#[derive(Debug, Clone, PartialEq, Eq)]
480pub struct ConfigPath {
481 pub path: PathBuf,
482 pub explicit: bool,
483}
484
485#[must_use]
487pub fn resolve_config_path(
488 cli_override: Option<PathBuf>,
489 env_override: Option<&str>,
490 xdg_config_home: Option<&str>,
491 home: Option<&str>,
492) -> Option<ConfigPath> {
493 if let Some(p) = cli_override.filter(|p| !p.as_os_str().is_empty()) {
494 return Some(ConfigPath {
495 path: p,
496 explicit: true,
497 });
498 }
499 if let Some(p) = env_override.filter(|s| !s.is_empty()) {
500 return Some(ConfigPath {
501 path: PathBuf::from(p),
502 explicit: true,
503 });
504 }
505 if let Some(p) = xdg_config_home.filter(|s| !s.is_empty()) {
506 return Some(ConfigPath {
507 path: PathBuf::from(p).join("linesmith").join("config.toml"),
508 explicit: false,
509 });
510 }
511 home.filter(|s| !s.is_empty()).map(|h| ConfigPath {
512 path: PathBuf::from(h).join(".config/linesmith/config.toml"),
513 explicit: false,
514 })
515}
516
517#[must_use]
520pub fn detect_config_path(cli_override: Option<PathBuf>) -> Option<ConfigPath> {
521 let env_override = std::env::var("LINESMITH_CONFIG").ok();
522 let xdg_config_home = std::env::var("XDG_CONFIG_HOME").ok();
523 let home = std::env::var("HOME").ok();
524 resolve_config_path(
525 cli_override,
526 env_override.as_deref(),
527 xdg_config_home.as_deref(),
528 home.as_deref(),
529 )
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
535
536 #[test]
539 fn empty_config_parses() {
540 let c = Config::from_str("").expect("parse ok");
541 assert_eq!(c.line, None);
542 assert!(c.segments.is_empty());
543 }
544
545 #[test]
546 fn line_segments_parse_in_order() {
547 let c = Config::from_str(
548 r#"
549 [line]
550 segments = ["model", "workspace", "cost"]
551 "#,
552 )
553 .expect("parse ok");
554 let line = c.line.expect("line present");
555 assert_eq!(line.segments, vec!["model", "workspace", "cost"]);
556 assert!(line.numbered.is_empty(), "no numbered tables expected");
557 }
558
559 #[test]
560 fn layout_field_defaults_to_single_line_when_omitted() {
561 let c = Config::from_str("").expect("parse ok");
562 assert_eq!(c.layout, LayoutMode::SingleLine);
563 }
564
565 #[test]
566 fn layout_field_parses_kebab_case_variants() {
567 let c = Config::from_str(r#"layout = "single-line""#).expect("parse ok");
568 assert_eq!(c.layout, LayoutMode::SingleLine);
569 let c = Config::from_str(r#"layout = "multi-line""#).expect("parse ok");
570 assert_eq!(c.layout, LayoutMode::MultiLine);
571 }
572
573 fn numbered_segments(value: &toml::Value) -> Vec<String> {
578 let table = value.as_table().expect("expected table value");
579 let array = table["segments"]
580 .as_array()
581 .expect("expected segments array");
582 array
583 .iter()
584 .map(|v| v.as_str().expect("expected string").to_string())
585 .collect()
586 }
587
588 #[test]
589 fn line_numbered_only_parses() {
590 let c = Config::from_str(
593 r#"
594 [line.1]
595 segments = ["model"]
596 [line.2]
597 segments = ["workspace", "cost"]
598 "#,
599 )
600 .expect("parse ok");
601 let line = c.line.expect("line present");
602 assert!(
603 line.segments.is_empty(),
604 "no top-level segments key expected"
605 );
606 assert_eq!(line.numbered.len(), 2);
607 assert_eq!(numbered_segments(&line.numbered["1"]), vec!["model"]);
608 assert_eq!(
609 numbered_segments(&line.numbered["2"]),
610 vec!["workspace", "cost"]
611 );
612 }
613
614 #[test]
615 fn line_with_segments_and_numbered_children_coexist() {
616 let c = Config::from_str(
621 r#"
622 [line]
623 segments = ["fallback"]
624 [line.1]
625 segments = ["a", "b"]
626 [line.2]
627 segments = ["c"]
628 "#,
629 )
630 .expect("parse ok");
631 let line = c.line.expect("line present");
632 assert_eq!(line.segments, vec!["fallback"]);
633 assert_eq!(line.numbered.len(), 2);
634 assert_eq!(numbered_segments(&line.numbered["1"]), vec!["a", "b"]);
635 assert_eq!(numbered_segments(&line.numbered["2"]), vec!["c"]);
636 }
637
638 #[test]
639 fn line_numbered_keys_preserved_verbatim_for_builder_validation() {
640 let c = Config::from_str(
646 r#"
647 [line.foo]
648 segments = ["bogus"]
649 [line.10]
650 segments = ["valid"]
651 "#,
652 )
653 .expect("parse ok");
654 let line = c.line.expect("line present");
655 assert_eq!(line.numbered.len(), 2);
656 assert!(line.numbered.contains_key("foo"));
657 assert!(line.numbered.contains_key("10"));
658 }
659
660 #[test]
661 fn line_unknown_scalar_key_does_not_fail_parse_forward_compat() {
662 let c = Config::from_str(
671 r#"
672 [line]
673 segments = ["model"]
674 segmnts = ["typo"] # scalar / array
675 future_separator = " | " # scalar string
676 [line.1]
677 segments = ["valid"]
678 "#,
679 )
680 .expect("parse ok despite unknown sibling keys");
681 let line = c.line.expect("line present");
682 assert_eq!(line.segments, vec!["model"]);
683 assert!(line.numbered.contains_key("segmnts"));
686 assert!(line.numbered.contains_key("future_separator"));
687 assert!(line.numbered.contains_key("1"));
688 }
689
690 #[test]
691 fn segment_override_priority_parses() {
692 let c = Config::from_str(
693 r#"
694 [segments.model]
695 priority = 16
696 "#,
697 )
698 .expect("parse ok");
699 assert_eq!(c.segments["model"].priority, Some(16));
700 assert_eq!(c.segments["model"].width, None);
701 }
702
703 #[test]
704 fn layout_options_color_and_padding_parse() {
705 let c = Config::from_str(
706 r#"
707 [layout_options]
708 color = "always"
709 claude_padding = 3
710 "#,
711 )
712 .expect("parse ok");
713 let lo = c.layout_options.expect("layout_options present");
714 assert_eq!(lo.color, ColorPolicy::Always);
715 assert_eq!(lo.claude_padding, 3);
716 }
717
718 #[test]
719 fn layout_options_color_accepts_all_three_variants() {
720 for (toml_val, expected) in [
721 ("auto", ColorPolicy::Auto),
722 ("always", ColorPolicy::Always),
723 ("never", ColorPolicy::Never),
724 ] {
725 let src = format!("[layout_options]\ncolor = \"{toml_val}\"\n");
726 let c = Config::from_str(&src).expect("parse ok");
727 assert_eq!(c.layout_options.map(|l| l.color), Some(expected));
728 }
729 }
730
731 fn collect_warnings(src: &str) -> Vec<String> {
734 let mut warnings = Vec::new();
735 let _ = Config::from_str_validated(src, |msg| warnings.push(msg.to_string()));
736 warnings
737 }
738
739 #[test]
740 fn plugin_dirs_deserializes_from_toml_as_path_list() {
741 let cfg: Config = Config::from_str(
747 r#"
748 plugin_dirs = ["/etc/linesmith/segments", "./vendor/plugins"]
749 [line]
750 segments = ["model"]
751 "#,
752 )
753 .expect("parse");
754 assert_eq!(
755 cfg.plugin_dirs,
756 vec![
757 PathBuf::from("/etc/linesmith/segments"),
758 PathBuf::from("./vendor/plugins"),
759 ]
760 );
761 }
762
763 #[test]
764 fn plugin_dirs_defaults_to_empty_when_absent() {
765 let cfg: Config = Config::from_str("theme = \"default\"\n").expect("parse");
766 assert!(cfg.plugin_dirs.is_empty());
767 }
768
769 #[test]
770 fn from_str_validated_warns_on_unknown_top_level_key() {
771 let warnings = collect_warnings("thme = \"oops\"\n[line]\nsegments = []\n");
772 assert_eq!(warnings.len(), 1);
773 assert!(warnings[0].contains("thme"));
774 assert!(warnings[0].contains("top-level"));
775 }
776
777 #[test]
778 fn from_str_validated_allows_implemented_and_forward_compat_top_level_keys() {
779 let toml = r#"
785 "$schema" = "https://example.invalid/schema.json"
786 theme = "default"
787 preset = "developer"
788 layout = "single-line"
789 [line]
790 segments = ["model"]
791 [layout_options]
792 color = "auto"
793 [plugins.example]
794 foo = "bar"
795 "#;
796 let warnings = collect_warnings(toml);
797 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
798 let cfg = Config::from_str(toml).expect("parses");
799 assert_eq!(cfg.preset.as_deref(), Some("developer"));
800 assert_eq!(
801 cfg.schema_url.as_deref(),
802 Some("https://example.invalid/schema.json")
803 );
804 let plugins = cfg.plugins.expect("plugins table populated");
805 assert!(plugins.contains_key("example"));
806 }
807
808 #[test]
809 fn schema_for_config_round_trips_as_valid_json() {
810 let schema = schemars::schema_for!(Config);
816 let json = serde_json::to_string(&schema).expect("schema serializes as JSON");
817 let parsed: serde_json::Value =
818 serde_json::from_str(&json).expect("schema round-trips as JSON");
819 let obj = parsed.as_object().expect("schema root is an object");
820 assert_eq!(
821 obj.get("$schema").and_then(|v| v.as_str()),
822 Some("https://json-schema.org/draft/2020-12/schema"),
823 "schema must declare its meta-schema URI"
824 );
825 assert_eq!(
826 obj.get("title").and_then(|v| v.as_str()),
827 Some("Config"),
828 "schema must title the root type"
829 );
830 let properties = obj
836 .get("properties")
837 .and_then(|v| v.as_object())
838 .expect("schema declares properties");
839 for key in ["preset", "plugins", "$schema"] {
840 assert!(
841 properties.contains_key(key),
842 "schema must expose {key:?} as a top-level property"
843 );
844 }
845 }
846
847 #[test]
848 fn schema_directive_wrapped_body_round_trips_as_toml() {
849 let body = "[line]\nsegments = [\"model\"]\n";
856 let wrapped = with_schema_directive(body);
857 assert!(
858 wrapped.starts_with("#:schema https://"),
859 "directive at byte 0"
860 );
861 assert!(
862 wrapped.contains("\n\n["),
863 "blank-line separator before first table"
864 );
865 let parsed: Config = wrapped.parse().expect("wrapped body parses as Config");
866 assert_eq!(
867 parsed.line.expect("line").segments,
868 vec!["model".to_string()]
869 );
870 }
871
872 #[test]
873 fn from_str_validated_warns_on_unknown_layout_options_key() {
874 let warnings = collect_warnings(
875 r#"
876 [layout_options]
877 separatr = "powerline"
878 "#,
879 );
880 assert_eq!(warnings.len(), 1);
881 assert!(warnings[0].contains("separatr"));
882 assert!(warnings[0].contains("[layout_options]"));
883 }
884
885 #[test]
886 fn from_str_validated_allows_separator_and_other_known_layout_options_keys() {
887 let warnings = collect_warnings(
892 r#"
893 [layout_options]
894 color = "always"
895 claude_padding = 2
896 separator = "powerline"
897 "#,
898 );
899 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
900 }
901
902 #[test]
903 fn from_str_validated_warns_on_unknown_segment_override_key() {
904 let warnings = collect_warnings(
905 r#"
906 [segments.model]
907 priorty = 16
908 "#,
909 );
910 assert_eq!(warnings.len(), 1);
911 assert!(warnings[0].contains("priorty"));
912 assert!(warnings[0].contains("[segments.model]"));
913 }
914
915 #[test]
916 fn from_str_validated_names_the_segment_id_in_warnings() {
917 let warnings = collect_warnings(
920 r#"
921 [segments.workspace]
922 bogus = "x"
923 [segments.cost]
924 alsobogus = 1
925 "#,
926 );
927 assert_eq!(warnings.len(), 2);
928 assert!(warnings
929 .iter()
930 .any(|w| w.contains("[segments.workspace]") && w.contains("bogus")));
931 assert!(warnings
932 .iter()
933 .any(|w| w.contains("[segments.cost]") && w.contains("alsobogus")));
934 }
935
936 #[test]
937 fn from_str_validated_skips_unknown_segment_ids_because_plugins_own_their_schema() {
938 let warnings = collect_warnings(
943 r#"
944 [segments.my_plugin]
945 foo = "bar"
946 baz = 42
947
948 [segments.another_plugin]
949 show_ahead_behind = true
950 show_dirty = true
951 "#,
952 );
953 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
954 }
955
956 #[test]
957 fn from_str_validated_rejects_segment_specific_keys_on_wrong_built_in() {
958 let warnings = collect_warnings(
961 r#"
962 [segments.model]
963 show_dirty = true
964 "#,
965 );
966 assert_eq!(warnings.len(), 1);
967 assert!(warnings[0].contains("show_dirty"));
968 assert!(warnings[0].contains("[segments.model]"));
969 }
970
971 #[test]
972 fn from_str_validated_allows_spec_documented_segment_override_keys() {
973 let warnings = collect_warnings(
977 r#"
978 [segments.workspace]
979 priority = 16
980 width = { min = 10, max = 40 }
981 style = "role:info"
982 visible_if = "true"
983 "#,
984 );
985 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
986 }
987
988 #[test]
989 fn model_segment_allows_format_key_without_warning() {
990 let warnings = collect_warnings(
991 r#"
992 [segments.model]
993 format = "compact"
994 "#,
995 );
996 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
997
998 let warnings_full = collect_warnings(
999 r#"
1000 [segments.model]
1001 format = "full"
1002 "#,
1003 );
1004 assert!(
1005 warnings_full.is_empty(),
1006 "unexpected warnings: {warnings_full:?}"
1007 );
1008 }
1009
1010 #[test]
1011 fn workspace_segment_warns_when_format_key_set() {
1012 let warnings = collect_warnings(
1016 r#"
1017 [segments.workspace]
1018 format = "compact"
1019 "#,
1020 );
1021 assert_eq!(warnings.len(), 1);
1022 assert!(warnings[0].contains("format"));
1023 assert!(warnings[0].contains("[segments.workspace]"));
1024 }
1025
1026 #[test]
1027 fn git_branch_allows_per_marker_hide_below_cells_without_warning() {
1028 let warnings = collect_warnings(
1033 r#"
1034 [segments.git_branch.dirty]
1035 hide_below_cells = 50
1036
1037 [segments.git_branch.ahead_behind]
1038 hide_below_cells = 80
1039 "#,
1040 );
1041 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1042 }
1043
1044 #[test]
1045 fn rate_limit_percent_segments_allow_format_and_invert_without_warning() {
1046 let warnings = collect_warnings(
1047 r#"
1048 [segments.rate_limit_5h]
1049 format = "progress"
1050 invert = true
1051 icon = "⏱"
1052 label = "5h"
1053 stale_marker = "~"
1054 progress_width = 20
1055
1056 [segments.rate_limit_7d]
1057 format = "percent"
1058 invert = false
1059 "#,
1060 );
1061 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1062 }
1063
1064 #[test]
1065 fn rate_limit_reset_segments_allow_compact_and_use_days_without_warning() {
1066 let warnings = collect_warnings(
1067 r#"
1068 [segments.rate_limit_5h_reset]
1069 format = "duration"
1070 compact = true
1071 use_days = false
1072
1073 [segments.rate_limit_7d_reset]
1074 format = "progress"
1075 use_days = true
1076 "#,
1077 );
1078 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1079 }
1080
1081 #[test]
1082 fn extra_usage_allows_currency_and_percent_format_without_warning() {
1083 let warnings = collect_warnings(
1084 r#"
1085 [segments.extra_usage]
1086 format = "currency"
1087 icon = ""
1088 label = "extra"
1089 stale_marker = "~"
1090 "#,
1091 );
1092 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1093 }
1094
1095 #[test]
1096 fn invert_warns_on_reset_segment_schema() {
1097 let warnings = collect_warnings(
1100 r#"
1101 [segments.rate_limit_5h_reset]
1102 invert = true
1103 "#,
1104 );
1105 assert_eq!(warnings.len(), 1);
1106 assert!(
1107 warnings[0].contains("invert") && warnings[0].contains("rate_limit_5h_reset"),
1108 "{:?}",
1109 warnings[0]
1110 );
1111 }
1112
1113 #[test]
1114 fn use_days_warns_on_percent_segment_schema() {
1115 let warnings = collect_warnings(
1118 r#"
1119 [segments.rate_limit_5h]
1120 use_days = true
1121 "#,
1122 );
1123 assert_eq!(warnings.len(), 1);
1124 assert!(
1125 warnings[0].contains("use_days") && warnings[0].contains("rate_limit_5h"),
1126 "{:?}",
1127 warnings[0]
1128 );
1129 }
1130
1131 #[test]
1132 fn from_str_validated_returns_parse_error_for_malformed_toml() {
1133 let mut warnings = Vec::new();
1134 let err =
1135 Config::from_str_validated("[line\nsegments =", |msg| warnings.push(msg.to_string()))
1136 .unwrap_err();
1137 assert!(matches!(err, ConfigError::Parse { .. }));
1138 }
1139
1140 #[test]
1141 fn validated_and_silent_parse_yield_identical_config_on_clean_input() {
1142 let src = r#"
1145 theme = "default"
1146 [line]
1147 segments = ["model", "workspace"]
1148 [segments.model]
1149 priority = 8
1150 "#;
1151 let silent = Config::from_str(src).expect("silent parse");
1152 let validated = Config::from_str_validated(src, |_| {}).expect("validated parse");
1153 assert_eq!(silent, validated);
1154 }
1155
1156 #[test]
1157 fn load_validated_file_path_surfaces_parse_error_with_path() {
1158 let dir = tempdir();
1161 let path = dir.path().join("config.toml");
1162 std::fs::write(&path, "[line\nsegments =").unwrap();
1163 let err = Config::load_validated(&path, |_| {}).unwrap_err();
1164 match err {
1165 ConfigError::Parse { path: Some(p), .. } => assert_eq!(p, path),
1166 other => panic!("expected Parse with Some(path), got {other:?}"),
1167 }
1168 }
1169
1170 #[test]
1171 fn load_validated_returns_none_for_missing_file() {
1172 let dir = tempdir();
1173 let path = dir.path().join("missing.toml");
1174 let mut warnings = Vec::new();
1175 let got = Config::load_validated(&path, |m| warnings.push(m.to_string())).expect("ok");
1176 assert!(got.is_none());
1177 assert!(warnings.is_empty());
1178 }
1179
1180 #[test]
1181 fn load_validated_surfaces_unknown_key_warnings() {
1182 let dir = tempdir();
1183 let path = dir.path().join("config.toml");
1184 std::fs::write(&path, "thme = \"bad\"\n").unwrap();
1185 let mut warnings = Vec::new();
1186 let _ = Config::load_validated(&path, |m| warnings.push(m.to_string())).unwrap();
1187 assert_eq!(warnings.len(), 1);
1188 assert!(warnings[0].contains("thme"));
1189 }
1190
1191 #[test]
1192 fn layout_options_defaults_populate_missing_keys() {
1193 let c = Config::from_str("[layout_options]\n").expect("parse ok");
1196 let lo = c.layout_options.expect("layout_options present");
1197 assert_eq!(lo.color, ColorPolicy::Auto);
1198 assert_eq!(lo.claude_padding, 0);
1199 }
1200
1201 #[test]
1202 fn layout_options_rejects_unknown_color_variant() {
1203 let err = Config::from_str(
1204 r#"
1205 [layout_options]
1206 color = "bogus"
1207 "#,
1208 )
1209 .unwrap_err();
1210 assert!(matches!(err, ConfigError::Parse { .. }));
1211 }
1212
1213 #[test]
1214 fn layout_options_omitted_entirely_is_ok() {
1215 let c = Config::from_str("[line]\nsegments = [\"model\"]\n").expect("parse ok");
1216 assert!(c.layout_options.is_none());
1217 }
1218
1219 #[test]
1220 fn segment_override_width_parses_both_sides() {
1221 let c = Config::from_str(
1222 r#"
1223 [segments.workspace.width]
1224 min = 10
1225 max = 40
1226 "#,
1227 )
1228 .expect("parse ok");
1229 let w = c.segments["workspace"].width.expect("width present");
1230 assert_eq!(w.min, Some(10));
1231 assert_eq!(w.max, Some(40));
1232 }
1233
1234 #[test]
1235 fn unknown_top_level_key_is_forward_compatible() {
1236 let c = Config::from_str(
1240 r#"
1241 theme = "catppuccin-mocha"
1242 layout = "single-line"
1243 [layout_options]
1244 separator = "powerline"
1245 "#,
1246 )
1247 .expect("parse ok");
1248 assert_eq!(c.line, None);
1249 assert!(c.segments.is_empty());
1250 }
1251
1252 #[test]
1253 fn malformed_toml_reports_parse_error() {
1254 let err = Config::from_str("[line").unwrap_err();
1255 assert!(matches!(err, ConfigError::Parse { .. }));
1256 }
1257
1258 #[test]
1259 fn io_error_carries_path_in_display() {
1260 use std::io::ErrorKind;
1261 let err = ConfigError::Io {
1262 path: PathBuf::from("/etc/linesmith/config.toml"),
1263 source: std::io::Error::new(ErrorKind::PermissionDenied, "denied"),
1264 };
1265 let rendered = err.to_string();
1266 assert!(rendered.contains("/etc/linesmith/config.toml"));
1267 assert!(rendered.contains("denied"));
1268 }
1269
1270 #[test]
1271 fn bom_prefixed_config_parses() {
1272 let dir = tempdir();
1276 let path = dir.path().join("config.toml");
1277 std::fs::write(&path, "\u{FEFF}[line]\nsegments = [\"model\"]\n").unwrap();
1278 let c = Config::load(&path).expect("ok").expect("present");
1279 assert_eq!(c.line.expect("line").segments, vec!["model".to_string()]);
1280 }
1281
1282 #[test]
1283 fn load_returns_none_for_missing_file() {
1284 let dir = tempdir();
1285 let path = dir.path().join("nonexistent.toml");
1286 assert!(Config::load(&path).unwrap().is_none());
1287 }
1288
1289 fn resolved(
1292 cli: Option<&str>,
1293 env: Option<&str>,
1294 xdg: Option<&str>,
1295 home: Option<&str>,
1296 ) -> Option<ConfigPath> {
1297 resolve_config_path(cli.map(PathBuf::from), env, xdg, home)
1298 }
1299
1300 #[test]
1301 fn cli_override_wins_over_everything_and_is_explicit() {
1302 let got = resolved(
1303 Some("/explicit.toml"),
1304 Some("/env.toml"),
1305 Some("/xdg"),
1306 Some("/home"),
1307 )
1308 .expect("resolved");
1309 assert_eq!(got.path, PathBuf::from("/explicit.toml"));
1310 assert!(got.explicit);
1311 }
1312
1313 #[test]
1314 fn env_wins_over_xdg_and_home_and_is_explicit() {
1315 let got = resolved(None, Some("/env.toml"), Some("/xdg"), Some("/home")).expect("resolved");
1316 assert_eq!(got.path, PathBuf::from("/env.toml"));
1317 assert!(got.explicit);
1318 }
1319
1320 #[test]
1321 fn xdg_config_home_is_implicit() {
1322 let got = resolved(None, None, Some("/xdg"), Some("/home")).expect("resolved");
1323 assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1324 assert!(!got.explicit);
1325 }
1326
1327 #[test]
1328 fn home_fallback_is_implicit() {
1329 let got = resolved(None, None, None, Some("/home")).expect("resolved");
1330 assert_eq!(
1331 got.path,
1332 PathBuf::from("/home/.config/linesmith/config.toml")
1333 );
1334 assert!(!got.explicit);
1335 }
1336
1337 #[test]
1338 fn returns_none_when_no_home_and_no_xdg() {
1339 assert_eq!(resolved(None, None, None, None), None);
1340 }
1341
1342 #[test]
1343 fn empty_env_values_are_ignored() {
1344 let got = resolved(None, Some(""), Some(""), Some("/home")).expect("resolved");
1345 assert_eq!(
1346 got.path,
1347 PathBuf::from("/home/.config/linesmith/config.toml")
1348 );
1349 }
1350
1351 #[test]
1352 fn empty_cli_override_does_not_count_as_explicit() {
1353 let got = resolved(Some(""), None, Some("/xdg"), None).expect("resolved");
1357 assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1358 assert!(!got.explicit);
1359 }
1360
1361 struct TempDir(PathBuf);
1364
1365 impl TempDir {
1366 fn path(&self) -> &Path {
1367 &self.0
1368 }
1369 }
1370
1371 impl Drop for TempDir {
1372 fn drop(&mut self) {
1373 let _ = std::fs::remove_dir_all(&self.0);
1374 }
1375 }
1376
1377 fn tempdir() -> TempDir {
1378 use std::sync::atomic::{AtomicU64, Ordering};
1379 static COUNTER: AtomicU64 = AtomicU64::new(0);
1380 let base = std::env::temp_dir().join(format!(
1381 "linesmith-config-test-{}-{}",
1382 std::time::SystemTime::now()
1383 .duration_since(std::time::UNIX_EPOCH)
1384 .expect("clock")
1385 .as_nanos(),
1386 COUNTER.fetch_add(1, Ordering::Relaxed),
1387 ));
1388 std::fs::create_dir_all(&base).expect("mkdir");
1389 TempDir(base)
1390 }
1391}