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 #[serde(deserialize_with = "deserialize_line_entries")]
126 pub segments: Vec<LineEntry>,
127 #[serde(flatten)]
137 #[schemars(with = "serde_json::Value")]
138 pub numbered: BTreeMap<String, toml::Value>,
139}
140
141#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
157#[serde(untagged)]
158pub enum LineEntry {
159 Id(String),
161 Item(LineEntryItem),
165}
166
167#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
171#[serde(default)]
172pub struct LineEntryItem {
173 #[serde(rename = "type")]
176 pub kind: Option<String>,
177 pub character: Option<String>,
182 pub merge: Option<bool>,
187 #[serde(flatten)]
195 #[schemars(with = "serde_json::Value")]
196 pub extra: BTreeMap<String, toml::Value>,
197}
198
199impl LineEntry {
200 #[must_use]
204 pub fn kind(&self) -> Option<&str> {
205 match self {
206 Self::Id(s) => Some(s.as_str()),
207 Self::Item(item) => item.kind.as_deref(),
208 }
209 }
210
211 #[must_use]
215 pub fn is_separator(&self) -> bool {
216 self.kind() == Some("separator")
217 }
218
219 #[must_use]
221 pub fn segment_id(&self) -> Option<&str> {
222 match self.kind() {
223 Some("separator") | None => None,
224 Some(id) => Some(id),
225 }
226 }
227
228 #[must_use]
232 pub fn separator_character(&self) -> Option<&str> {
233 match self {
234 Self::Item(item) if item.kind.as_deref() == Some("separator") => {
235 item.character.as_deref()
236 }
237 _ => None,
238 }
239 }
240
241 #[must_use]
246 pub fn merge(&self) -> bool {
247 match self {
248 Self::Item(item) if item.kind.as_deref() != Some("separator") => {
249 item.merge.unwrap_or(false)
250 }
251 _ => false,
252 }
253 }
254}
255
256impl From<&str> for LineEntry {
257 fn from(s: &str) -> Self {
258 Self::Id(s.to_string())
259 }
260}
261
262impl From<String> for LineEntry {
263 fn from(s: String) -> Self {
264 Self::Id(s)
265 }
266}
267
268fn deserialize_line_entries<'de, D>(deserializer: D) -> Result<Vec<LineEntry>, D::Error>
281where
282 D: serde::Deserializer<'de>,
283{
284 let raw = Vec::<toml::Value>::deserialize(deserializer)?;
285 Ok(raw.into_iter().map(value_to_line_entry).collect())
286}
287
288fn value_to_line_entry(value: toml::Value) -> LineEntry {
289 if let toml::Value::String(s) = &value {
290 return LineEntry::Id(s.clone());
291 }
292 if let toml::Value::Table(_) = &value {
293 if let Ok(item) = value.clone().try_into::<LineEntryItem>() {
294 return LineEntry::Item(item);
295 }
296 }
297 let mut extra: BTreeMap<String, toml::Value> = BTreeMap::new();
304 if let toml::Value::Table(table) = value {
305 for (k, v) in table {
306 extra.insert(k, v);
307 }
308 } else {
309 extra.insert("__malformed__".to_string(), value);
310 }
311 LineEntry::Item(LineEntryItem {
312 kind: None,
313 character: None,
314 merge: None,
315 extra,
316 })
317}
318
319#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
323#[serde(rename_all = "kebab-case")]
324#[non_exhaustive]
325pub enum LayoutMode {
326 #[default]
327 SingleLine,
328 MultiLine,
329}
330
331#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
342#[serde(default)]
343pub struct SegmentOverride {
344 pub priority: Option<u8>,
345 pub width: Option<WidthBoundsConfig>,
346 pub style: Option<String>,
347 #[serde(flatten)]
357 #[schemars(with = "serde_json::Value")]
358 pub extra: BTreeMap<String, toml::Value>,
359}
360
361pub const SCHEMA_URL: &str =
371 "https://raw.githubusercontent.com/oakoss/linesmith/main/config.schema.json";
372
373pub fn with_schema_directive(body: &str) -> String {
377 format!("#:schema {SCHEMA_URL}\n\n{body}")
378}
379
380#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
383#[schemars(extend("additionalProperties" = false))]
384pub struct WidthBoundsConfig {
385 pub min: Option<u16>,
386 pub max: Option<u16>,
387}
388
389#[derive(Debug)]
392#[non_exhaustive]
393pub enum ConfigError {
394 Io {
397 path: PathBuf,
398 source: std::io::Error,
399 },
400 Parse {
402 path: Option<PathBuf>,
403 source: toml::de::Error,
404 },
405}
406
407impl std::fmt::Display for ConfigError {
408 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
409 match self {
410 Self::Io { path, source } => write!(f, "config I/O at {}: {source}", path.display()),
411 Self::Parse {
412 path: Some(p),
413 source,
414 } => write!(f, "config parse at {}: {source}", p.display()),
415 Self::Parse { path: None, source } => write!(f, "config parse: {source}"),
416 }
417 }
418}
419
420impl std::error::Error for ConfigError {
421 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
422 match self {
423 Self::Io { source, .. } => Some(source),
424 Self::Parse { source, .. } => Some(source),
425 }
426 }
427}
428
429impl FromStr for Config {
430 type Err = ConfigError;
431
432 fn from_str(s: &str) -> Result<Self, Self::Err> {
433 toml::from_str(s).map_err(|source| ConfigError::Parse { path: None, source })
434 }
435}
436
437impl Config {
438 pub fn load(path: &Path) -> Result<Option<Self>, ConfigError> {
444 let raw = match std::fs::read_to_string(path) {
445 Ok(s) => s,
446 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
447 Err(source) => {
448 return Err(ConfigError::Io {
449 path: path.to_owned(),
450 source,
451 })
452 }
453 };
454 toml::from_str(&raw)
455 .map(Some)
456 .map_err(|source| ConfigError::Parse {
457 path: Some(path.to_owned()),
458 source,
459 })
460 }
461
462 pub fn load_validated(
468 path: &Path,
469 warn: impl FnMut(&str),
470 ) -> Result<Option<Self>, ConfigError> {
471 let raw = match std::fs::read_to_string(path) {
472 Ok(s) => s,
473 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
474 Err(source) => {
475 return Err(ConfigError::Io {
476 path: path.to_owned(),
477 source,
478 })
479 }
480 };
481 Self::from_str_validated_impl(&raw, Some(path), warn).map(Some)
482 }
483
484 pub fn from_str_validated(s: &str, warn: impl FnMut(&str)) -> Result<Self, ConfigError> {
489 Self::from_str_validated_impl(s, None, warn)
490 }
491
492 fn from_str_validated_impl(
493 s: &str,
494 path: Option<&Path>,
495 mut warn: impl FnMut(&str),
496 ) -> Result<Self, ConfigError> {
497 let raw: toml::Value = toml::from_str(s).map_err(|source| ConfigError::Parse {
498 path: path.map(Path::to_owned),
499 source,
500 })?;
501 validate_keys(&raw, &mut warn);
502 raw.try_into()
503 .map_err(|source: toml::de::Error| ConfigError::Parse {
504 path: path.map(Path::to_owned),
505 source,
506 })
507 }
508}
509
510const KNOWN_TOP_LEVEL: &[&str] = &[
515 "line",
516 "theme",
517 "layout_options",
518 "segments",
519 "plugin_dirs",
520 "preset",
521 "layout",
522 "plugins",
523 "$schema",
524];
525
526const KNOWN_LAYOUT_OPTIONS: &[&str] = &["color", "claude_padding", "separator", "powerline_width"];
529
530fn segment_override_schema(id: &str) -> Option<&'static [&'static str]> {
536 const BUILT_IN_COMMON: &[&str] = &["priority", "width", "style", "visible_if"];
537 const RATE_LIMIT_COMMON: &[&str] = &[
538 "priority",
539 "width",
540 "style",
541 "visible_if",
542 "icon",
543 "label",
544 "stale_marker",
545 "progress_width",
546 "format",
547 ];
548 const PERCENT_SEGMENT: &[&str] = &[
549 "priority",
550 "width",
551 "style",
552 "visible_if",
553 "icon",
554 "label",
555 "stale_marker",
556 "progress_width",
557 "format",
558 "invert",
559 ];
560 const RESET_SEGMENT: &[&str] = &[
561 "priority",
562 "width",
563 "style",
564 "visible_if",
565 "icon",
566 "label",
567 "stale_marker",
568 "progress_width",
569 "format",
570 "compact",
571 "use_days",
572 "timezone",
575 "hour_format",
576 "locale",
577 ];
578 const GIT_BRANCH_SEGMENT: &[&str] = &[
583 "priority",
584 "width",
585 "style",
586 "visible_if",
587 "icon",
588 "label",
589 "max_length",
590 "truncation_marker",
591 "short_sha_length",
592 "dirty",
593 "ahead_behind",
594 ];
595 const MODEL_SEGMENT: &[&str] = &["priority", "width", "style", "visible_if", "format"];
596 match id {
597 "model" => Some(MODEL_SEGMENT),
598 "workspace" | "cost" | "effort" | "context_window" => Some(BUILT_IN_COMMON),
599 "rate_limit_5h" | "rate_limit_7d" => Some(PERCENT_SEGMENT),
600 "rate_limit_5h_reset" | "rate_limit_7d_reset" => Some(RESET_SEGMENT),
601 "extra_usage" => Some(RATE_LIMIT_COMMON),
602 "git_branch" => Some(GIT_BRANCH_SEGMENT),
603 _ => None,
604 }
605}
606
607fn validate_keys(raw: &toml::Value, warn: &mut impl FnMut(&str)) {
613 let Some(top) = raw.as_table() else {
614 return;
615 };
616 for (key, value) in top {
617 if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
618 warn(&format!("unknown top-level config key '{key}'; ignoring"));
619 continue;
620 }
621 match key.as_str() {
622 "layout_options" => {
623 validate_flat_table(value, "layout_options", KNOWN_LAYOUT_OPTIONS, warn)
624 }
625 "segments" => validate_segments_table(value, warn),
626 _ => {}
627 }
628 }
629}
630
631fn validate_flat_table(
632 value: &toml::Value,
633 label: &str,
634 allowed: &[&str],
635 warn: &mut impl FnMut(&str),
636) {
637 let Some(table) = value.as_table() else {
638 return;
639 };
640 for key in table.keys() {
641 if !allowed.contains(&key.as_str()) {
642 warn(&format!("unknown key '{key}' in [{label}]; ignoring"));
643 }
644 }
645}
646
647fn validate_segments_table(value: &toml::Value, warn: &mut impl FnMut(&str)) {
648 let Some(segments) = value.as_table() else {
649 return;
650 };
651 for (id, block) in segments {
652 let Some(block_table) = block.as_table() else {
653 continue;
654 };
655 let Some(allowed) = segment_override_schema(id) else {
656 continue;
660 };
661 for key in block_table.keys() {
662 if !allowed.contains(&key.as_str()) {
663 warn(&format!("unknown key '{key}' in [segments.{id}]; ignoring"));
664 }
665 }
666 }
667}
668
669#[derive(Debug, Clone, PartialEq, Eq)]
675pub struct ConfigPath {
676 pub path: PathBuf,
677 pub explicit: bool,
678}
679
680#[must_use]
685pub fn resolve_config_path(
686 cli_override: Option<PathBuf>,
687 env_override: Option<&std::ffi::OsStr>,
688 xdg_config_home: Option<&std::ffi::OsStr>,
689 home: Option<&std::ffi::OsStr>,
690) -> Option<ConfigPath> {
691 if let Some(p) = cli_override.filter(|p| !p.as_os_str().is_empty()) {
692 return Some(ConfigPath {
693 path: p,
694 explicit: true,
695 });
696 }
697 if let Some(p) = env_override.filter(|s| !s.is_empty()) {
698 return Some(ConfigPath {
699 path: PathBuf::from(p),
700 explicit: true,
701 });
702 }
703 if let Some(p) = xdg_config_home.filter(|s| !s.is_empty()) {
704 return Some(ConfigPath {
705 path: PathBuf::from(p).join("linesmith").join("config.toml"),
706 explicit: false,
707 });
708 }
709 home.filter(|s| !s.is_empty()).map(|h| ConfigPath {
710 path: PathBuf::from(h).join(".config/linesmith/config.toml"),
711 explicit: false,
712 })
713}
714
715#[must_use]
718pub fn detect_config_path(cli_override: Option<PathBuf>) -> Option<ConfigPath> {
719 let env_override = std::env::var_os("LINESMITH_CONFIG");
720 let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
721 let home = std::env::var_os("HOME");
722 resolve_config_path(
723 cli_override,
724 env_override.as_deref(),
725 xdg_config_home.as_deref(),
726 home.as_deref(),
727 )
728}
729
730#[cfg(test)]
731mod tests {
732 use super::*;
733
734 #[test]
737 fn empty_config_parses() {
738 let c = Config::from_str("").expect("parse ok");
739 assert_eq!(c.line, None);
740 assert!(c.segments.is_empty());
741 }
742
743 #[test]
744 fn line_segments_parse_in_order() {
745 let c = Config::from_str(
746 r#"
747 [line]
748 segments = ["model", "workspace", "cost"]
749 "#,
750 )
751 .expect("parse ok");
752 let line = c.line.expect("line present");
753 assert_eq!(
754 entry_ids(&line.segments),
755 vec!["model", "workspace", "cost"]
756 );
757 assert!(line.numbered.is_empty(), "no numbered tables expected");
758 }
759
760 #[test]
761 fn layout_field_defaults_to_single_line_when_omitted() {
762 let c = Config::from_str("").expect("parse ok");
763 assert_eq!(c.layout, LayoutMode::SingleLine);
764 }
765
766 #[test]
767 fn layout_field_parses_kebab_case_variants() {
768 let c = Config::from_str(r#"layout = "single-line""#).expect("parse ok");
769 assert_eq!(c.layout, LayoutMode::SingleLine);
770 let c = Config::from_str(r#"layout = "multi-line""#).expect("parse ok");
771 assert_eq!(c.layout, LayoutMode::MultiLine);
772 }
773
774 fn numbered_segments(value: &toml::Value) -> Vec<String> {
779 let table = value.as_table().expect("expected table value");
780 let array = table["segments"]
781 .as_array()
782 .expect("expected segments array");
783 array
784 .iter()
785 .map(|v| v.as_str().expect("expected string").to_string())
786 .collect()
787 }
788
789 fn entry_ids(entries: &[LineEntry]) -> Vec<&str> {
795 entries.iter().filter_map(LineEntry::segment_id).collect()
796 }
797
798 #[test]
799 fn line_numbered_only_parses() {
800 let c = Config::from_str(
803 r#"
804 [line.1]
805 segments = ["model"]
806 [line.2]
807 segments = ["workspace", "cost"]
808 "#,
809 )
810 .expect("parse ok");
811 let line = c.line.expect("line present");
812 assert!(
813 line.segments.is_empty(),
814 "no top-level segments key expected"
815 );
816 assert_eq!(line.numbered.len(), 2);
817 assert_eq!(numbered_segments(&line.numbered["1"]), vec!["model"]);
818 assert_eq!(
819 numbered_segments(&line.numbered["2"]),
820 vec!["workspace", "cost"]
821 );
822 }
823
824 #[test]
825 fn line_with_segments_and_numbered_children_coexist() {
826 let c = Config::from_str(
831 r#"
832 [line]
833 segments = ["fallback"]
834 [line.1]
835 segments = ["a", "b"]
836 [line.2]
837 segments = ["c"]
838 "#,
839 )
840 .expect("parse ok");
841 let line = c.line.expect("line present");
842 assert_eq!(entry_ids(&line.segments), vec!["fallback"]);
843 assert_eq!(line.numbered.len(), 2);
844 assert_eq!(numbered_segments(&line.numbered["1"]), vec!["a", "b"]);
845 assert_eq!(numbered_segments(&line.numbered["2"]), vec!["c"]);
846 }
847
848 #[test]
849 fn line_numbered_keys_preserved_verbatim_for_builder_validation() {
850 let c = Config::from_str(
856 r#"
857 [line.foo]
858 segments = ["bogus"]
859 [line.10]
860 segments = ["valid"]
861 "#,
862 )
863 .expect("parse ok");
864 let line = c.line.expect("line present");
865 assert_eq!(line.numbered.len(), 2);
866 assert!(line.numbered.contains_key("foo"));
867 assert!(line.numbered.contains_key("10"));
868 }
869
870 #[test]
871 fn line_unknown_scalar_key_does_not_fail_parse_forward_compat() {
872 let c = Config::from_str(
881 r#"
882 [line]
883 segments = ["model"]
884 segmnts = ["typo"] # scalar / array
885 future_separator = " | " # scalar string
886 [line.1]
887 segments = ["valid"]
888 "#,
889 )
890 .expect("parse ok despite unknown sibling keys");
891 let line = c.line.expect("line present");
892 assert_eq!(entry_ids(&line.segments), vec!["model"]);
893 assert!(line.numbered.contains_key("segmnts"));
896 assert!(line.numbered.contains_key("future_separator"));
897 assert!(line.numbered.contains_key("1"));
898 }
899
900 #[test]
901 fn segment_override_priority_parses() {
902 let c = Config::from_str(
903 r#"
904 [segments.model]
905 priority = 16
906 "#,
907 )
908 .expect("parse ok");
909 assert_eq!(c.segments["model"].priority, Some(16));
910 assert_eq!(c.segments["model"].width, None);
911 }
912
913 #[test]
914 fn layout_options_color_and_padding_parse() {
915 let c = Config::from_str(
916 r#"
917 [layout_options]
918 color = "always"
919 claude_padding = 3
920 "#,
921 )
922 .expect("parse ok");
923 let lo = c.layout_options.expect("layout_options present");
924 assert_eq!(lo.color, ColorPolicy::Always);
925 assert_eq!(lo.claude_padding, 3);
926 }
927
928 #[test]
929 fn layout_options_color_accepts_all_three_variants() {
930 for (toml_val, expected) in [
931 ("auto", ColorPolicy::Auto),
932 ("always", ColorPolicy::Always),
933 ("never", ColorPolicy::Never),
934 ] {
935 let src = format!("[layout_options]\ncolor = \"{toml_val}\"\n");
936 let c = Config::from_str(&src).expect("parse ok");
937 assert_eq!(c.layout_options.map(|l| l.color), Some(expected));
938 }
939 }
940
941 fn collect_warnings(src: &str) -> Vec<String> {
944 let mut warnings = Vec::new();
945 let _ = Config::from_str_validated(src, |msg| warnings.push(msg.to_string()));
946 warnings
947 }
948
949 #[test]
950 fn plugin_dirs_deserializes_from_toml_as_path_list() {
951 let cfg: Config = Config::from_str(
957 r#"
958 plugin_dirs = ["/etc/linesmith/segments", "./vendor/plugins"]
959 [line]
960 segments = ["model"]
961 "#,
962 )
963 .expect("parse");
964 assert_eq!(
965 cfg.plugin_dirs,
966 vec![
967 PathBuf::from("/etc/linesmith/segments"),
968 PathBuf::from("./vendor/plugins"),
969 ]
970 );
971 }
972
973 #[test]
974 fn plugin_dirs_defaults_to_empty_when_absent() {
975 let cfg: Config = Config::from_str("theme = \"default\"\n").expect("parse");
976 assert!(cfg.plugin_dirs.is_empty());
977 }
978
979 #[test]
980 fn from_str_validated_warns_on_unknown_top_level_key() {
981 let warnings = collect_warnings("thme = \"oops\"\n[line]\nsegments = []\n");
982 assert_eq!(warnings.len(), 1);
983 assert!(warnings[0].contains("thme"));
984 assert!(warnings[0].contains("top-level"));
985 }
986
987 #[test]
988 fn from_str_validated_allows_implemented_and_forward_compat_top_level_keys() {
989 let toml = r#"
995 "$schema" = "https://example.invalid/schema.json"
996 theme = "default"
997 preset = "developer"
998 layout = "single-line"
999 [line]
1000 segments = ["model"]
1001 [layout_options]
1002 color = "auto"
1003 [plugins.example]
1004 foo = "bar"
1005 "#;
1006 let warnings = collect_warnings(toml);
1007 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1008 let cfg = Config::from_str(toml).expect("parses");
1009 assert_eq!(cfg.preset.as_deref(), Some("developer"));
1010 assert_eq!(
1011 cfg.schema_url.as_deref(),
1012 Some("https://example.invalid/schema.json")
1013 );
1014 let plugins = cfg.plugins.expect("plugins table populated");
1015 assert!(plugins.contains_key("example"));
1016 }
1017
1018 #[test]
1019 fn schema_for_config_round_trips_as_valid_json() {
1020 let schema = schemars::schema_for!(Config);
1026 let json = serde_json::to_string(&schema).expect("schema serializes as JSON");
1027 let parsed: serde_json::Value =
1028 serde_json::from_str(&json).expect("schema round-trips as JSON");
1029 let obj = parsed.as_object().expect("schema root is an object");
1030 assert_eq!(
1031 obj.get("$schema").and_then(|v| v.as_str()),
1032 Some("https://json-schema.org/draft/2020-12/schema"),
1033 "schema must declare its meta-schema URI"
1034 );
1035 assert_eq!(
1036 obj.get("title").and_then(|v| v.as_str()),
1037 Some("Config"),
1038 "schema must title the root type"
1039 );
1040 let properties = obj
1046 .get("properties")
1047 .and_then(|v| v.as_object())
1048 .expect("schema declares properties");
1049 for key in ["preset", "plugins", "$schema"] {
1050 assert!(
1051 properties.contains_key(key),
1052 "schema must expose {key:?} as a top-level property"
1053 );
1054 }
1055 }
1056
1057 #[test]
1058 fn schema_directive_wrapped_body_round_trips_as_toml() {
1059 let body = "[line]\nsegments = [\"model\"]\n";
1066 let wrapped = with_schema_directive(body);
1067 assert!(
1068 wrapped.starts_with("#:schema https://"),
1069 "directive at byte 0"
1070 );
1071 assert!(
1072 wrapped.contains("\n\n["),
1073 "blank-line separator before first table"
1074 );
1075 let parsed: Config = wrapped.parse().expect("wrapped body parses as Config");
1076 assert_eq!(
1077 entry_ids(&parsed.line.expect("line").segments),
1078 vec!["model"]
1079 );
1080 }
1081
1082 #[test]
1083 fn from_str_validated_warns_on_unknown_layout_options_key() {
1084 let warnings = collect_warnings(
1085 r#"
1086 [layout_options]
1087 separatr = "powerline"
1088 "#,
1089 );
1090 assert_eq!(warnings.len(), 1);
1091 assert!(warnings[0].contains("separatr"));
1092 assert!(warnings[0].contains("[layout_options]"));
1093 }
1094
1095 #[test]
1096 fn from_str_validated_allows_separator_and_other_known_layout_options_keys() {
1097 let warnings = collect_warnings(
1102 r#"
1103 [layout_options]
1104 color = "always"
1105 claude_padding = 2
1106 separator = "powerline"
1107 "#,
1108 );
1109 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1110 }
1111
1112 #[test]
1113 fn from_str_validated_warns_on_unknown_segment_override_key() {
1114 let warnings = collect_warnings(
1115 r#"
1116 [segments.model]
1117 priorty = 16
1118 "#,
1119 );
1120 assert_eq!(warnings.len(), 1);
1121 assert!(warnings[0].contains("priorty"));
1122 assert!(warnings[0].contains("[segments.model]"));
1123 }
1124
1125 #[test]
1126 fn from_str_validated_names_the_segment_id_in_warnings() {
1127 let warnings = collect_warnings(
1130 r#"
1131 [segments.workspace]
1132 bogus = "x"
1133 [segments.cost]
1134 alsobogus = 1
1135 "#,
1136 );
1137 assert_eq!(warnings.len(), 2);
1138 assert!(warnings
1139 .iter()
1140 .any(|w| w.contains("[segments.workspace]") && w.contains("bogus")));
1141 assert!(warnings
1142 .iter()
1143 .any(|w| w.contains("[segments.cost]") && w.contains("alsobogus")));
1144 }
1145
1146 #[test]
1147 fn from_str_validated_skips_unknown_segment_ids_because_plugins_own_their_schema() {
1148 let warnings = collect_warnings(
1153 r#"
1154 [segments.my_plugin]
1155 foo = "bar"
1156 baz = 42
1157
1158 [segments.another_plugin]
1159 show_ahead_behind = true
1160 show_dirty = true
1161 "#,
1162 );
1163 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1164 }
1165
1166 #[test]
1167 fn from_str_validated_rejects_segment_specific_keys_on_wrong_built_in() {
1168 let warnings = collect_warnings(
1171 r#"
1172 [segments.model]
1173 show_dirty = true
1174 "#,
1175 );
1176 assert_eq!(warnings.len(), 1);
1177 assert!(warnings[0].contains("show_dirty"));
1178 assert!(warnings[0].contains("[segments.model]"));
1179 }
1180
1181 #[test]
1182 fn from_str_validated_allows_spec_documented_segment_override_keys() {
1183 let warnings = collect_warnings(
1187 r#"
1188 [segments.workspace]
1189 priority = 16
1190 width = { min = 10, max = 40 }
1191 style = "role:info"
1192 visible_if = "true"
1193 "#,
1194 );
1195 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1196 }
1197
1198 #[test]
1199 fn reset_segment_allows_absolute_format_keys_without_warning() {
1200 let warnings = collect_warnings(
1201 r#"
1202 [segments.rate_limit_5h_reset]
1203 format = "absolute"
1204 timezone = "America/Los_Angeles"
1205 hour_format = "12h"
1206 locale = "en-US"
1207
1208 [segments.rate_limit_7d_reset]
1209 format = "absolute"
1210 timezone = "Europe/London"
1211 hour_format = "24h"
1212 "#,
1213 );
1214 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1215 }
1216
1217 #[test]
1218 fn model_segment_allows_format_key_without_warning() {
1219 let warnings = collect_warnings(
1220 r#"
1221 [segments.model]
1222 format = "compact"
1223 "#,
1224 );
1225 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1226
1227 let warnings_full = collect_warnings(
1228 r#"
1229 [segments.model]
1230 format = "full"
1231 "#,
1232 );
1233 assert!(
1234 warnings_full.is_empty(),
1235 "unexpected warnings: {warnings_full:?}"
1236 );
1237 }
1238
1239 #[test]
1240 fn workspace_segment_warns_when_format_key_set() {
1241 let warnings = collect_warnings(
1245 r#"
1246 [segments.workspace]
1247 format = "compact"
1248 "#,
1249 );
1250 assert_eq!(warnings.len(), 1);
1251 assert!(warnings[0].contains("format"));
1252 assert!(warnings[0].contains("[segments.workspace]"));
1253 }
1254
1255 #[test]
1256 fn git_branch_allows_per_marker_hide_below_cells_without_warning() {
1257 let warnings = collect_warnings(
1262 r#"
1263 [segments.git_branch.dirty]
1264 hide_below_cells = 50
1265
1266 [segments.git_branch.ahead_behind]
1267 hide_below_cells = 80
1268 "#,
1269 );
1270 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1271 }
1272
1273 #[test]
1274 fn rate_limit_percent_segments_allow_format_and_invert_without_warning() {
1275 let warnings = collect_warnings(
1276 r#"
1277 [segments.rate_limit_5h]
1278 format = "progress"
1279 invert = true
1280 icon = "⏱"
1281 label = "5h"
1282 stale_marker = "~"
1283 progress_width = 20
1284
1285 [segments.rate_limit_7d]
1286 format = "percent"
1287 invert = false
1288 "#,
1289 );
1290 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1291 }
1292
1293 #[test]
1294 fn rate_limit_reset_segments_allow_compact_and_use_days_without_warning() {
1295 let warnings = collect_warnings(
1296 r#"
1297 [segments.rate_limit_5h_reset]
1298 format = "duration"
1299 compact = true
1300 use_days = false
1301
1302 [segments.rate_limit_7d_reset]
1303 format = "progress"
1304 use_days = true
1305 "#,
1306 );
1307 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1308 }
1309
1310 #[test]
1311 fn extra_usage_allows_currency_and_percent_format_without_warning() {
1312 let warnings = collect_warnings(
1313 r#"
1314 [segments.extra_usage]
1315 format = "currency"
1316 icon = ""
1317 label = "extra"
1318 stale_marker = "~"
1319 "#,
1320 );
1321 assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
1322 }
1323
1324 #[test]
1325 fn invert_warns_on_reset_segment_schema() {
1326 let warnings = collect_warnings(
1329 r#"
1330 [segments.rate_limit_5h_reset]
1331 invert = true
1332 "#,
1333 );
1334 assert_eq!(warnings.len(), 1);
1335 assert!(
1336 warnings[0].contains("invert") && warnings[0].contains("rate_limit_5h_reset"),
1337 "{:?}",
1338 warnings[0]
1339 );
1340 }
1341
1342 #[test]
1343 fn use_days_warns_on_percent_segment_schema() {
1344 let warnings = collect_warnings(
1347 r#"
1348 [segments.rate_limit_5h]
1349 use_days = true
1350 "#,
1351 );
1352 assert_eq!(warnings.len(), 1);
1353 assert!(
1354 warnings[0].contains("use_days") && warnings[0].contains("rate_limit_5h"),
1355 "{:?}",
1356 warnings[0]
1357 );
1358 }
1359
1360 #[test]
1361 fn from_str_validated_returns_parse_error_for_malformed_toml() {
1362 let mut warnings = Vec::new();
1363 let err =
1364 Config::from_str_validated("[line\nsegments =", |msg| warnings.push(msg.to_string()))
1365 .unwrap_err();
1366 assert!(matches!(err, ConfigError::Parse { .. }));
1367 }
1368
1369 #[test]
1370 fn validated_and_silent_parse_yield_identical_config_on_clean_input() {
1371 let src = r#"
1374 theme = "default"
1375 [line]
1376 segments = ["model", "workspace"]
1377 [segments.model]
1378 priority = 8
1379 "#;
1380 let silent = Config::from_str(src).expect("silent parse");
1381 let validated = Config::from_str_validated(src, |_| {}).expect("validated parse");
1382 assert_eq!(silent, validated);
1383 }
1384
1385 #[test]
1386 fn load_validated_file_path_surfaces_parse_error_with_path() {
1387 let dir = tempdir();
1390 let path = dir.path().join("config.toml");
1391 std::fs::write(&path, "[line\nsegments =").unwrap();
1392 let err = Config::load_validated(&path, |_| {}).unwrap_err();
1393 match err {
1394 ConfigError::Parse { path: Some(p), .. } => assert_eq!(p, path),
1395 other => panic!("expected Parse with Some(path), got {other:?}"),
1396 }
1397 }
1398
1399 #[test]
1400 fn load_validated_returns_none_for_missing_file() {
1401 let dir = tempdir();
1402 let path = dir.path().join("missing.toml");
1403 let mut warnings = Vec::new();
1404 let got = Config::load_validated(&path, |m| warnings.push(m.to_string())).expect("ok");
1405 assert!(got.is_none());
1406 assert!(warnings.is_empty());
1407 }
1408
1409 #[test]
1410 fn load_validated_surfaces_unknown_key_warnings() {
1411 let dir = tempdir();
1412 let path = dir.path().join("config.toml");
1413 std::fs::write(&path, "thme = \"bad\"\n").unwrap();
1414 let mut warnings = Vec::new();
1415 let _ = Config::load_validated(&path, |m| warnings.push(m.to_string())).unwrap();
1416 assert_eq!(warnings.len(), 1);
1417 assert!(warnings[0].contains("thme"));
1418 }
1419
1420 #[test]
1421 fn layout_options_defaults_populate_missing_keys() {
1422 let c = Config::from_str("[layout_options]\n").expect("parse ok");
1425 let lo = c.layout_options.expect("layout_options present");
1426 assert_eq!(lo.color, ColorPolicy::Auto);
1427 assert_eq!(lo.claude_padding, 0);
1428 }
1429
1430 #[test]
1431 fn layout_options_rejects_unknown_color_variant() {
1432 let err = Config::from_str(
1433 r#"
1434 [layout_options]
1435 color = "bogus"
1436 "#,
1437 )
1438 .unwrap_err();
1439 assert!(matches!(err, ConfigError::Parse { .. }));
1440 }
1441
1442 #[test]
1443 fn layout_options_omitted_entirely_is_ok() {
1444 let c = Config::from_str("[line]\nsegments = [\"model\"]\n").expect("parse ok");
1445 assert!(c.layout_options.is_none());
1446 }
1447
1448 #[test]
1449 fn segment_override_width_parses_both_sides() {
1450 let c = Config::from_str(
1451 r#"
1452 [segments.workspace.width]
1453 min = 10
1454 max = 40
1455 "#,
1456 )
1457 .expect("parse ok");
1458 let w = c.segments["workspace"].width.expect("width present");
1459 assert_eq!(w.min, Some(10));
1460 assert_eq!(w.max, Some(40));
1461 }
1462
1463 #[test]
1464 fn unknown_top_level_key_is_forward_compatible() {
1465 let c = Config::from_str(
1469 r#"
1470 theme = "catppuccin-mocha"
1471 layout = "single-line"
1472 [layout_options]
1473 separator = "powerline"
1474 "#,
1475 )
1476 .expect("parse ok");
1477 assert_eq!(c.line, None);
1478 assert!(c.segments.is_empty());
1479 }
1480
1481 #[test]
1482 fn malformed_toml_reports_parse_error() {
1483 let err = Config::from_str("[line").unwrap_err();
1484 assert!(matches!(err, ConfigError::Parse { .. }));
1485 }
1486
1487 #[test]
1488 fn io_error_carries_path_in_display() {
1489 use std::io::ErrorKind;
1490 let err = ConfigError::Io {
1491 path: PathBuf::from("/etc/linesmith/config.toml"),
1492 source: std::io::Error::new(ErrorKind::PermissionDenied, "denied"),
1493 };
1494 let rendered = err.to_string();
1495 assert!(rendered.contains("/etc/linesmith/config.toml"));
1496 assert!(rendered.contains("denied"));
1497 }
1498
1499 #[test]
1500 fn bom_prefixed_config_parses() {
1501 let dir = tempdir();
1505 let path = dir.path().join("config.toml");
1506 std::fs::write(&path, "\u{FEFF}[line]\nsegments = [\"model\"]\n").unwrap();
1507 let c = Config::load(&path).expect("ok").expect("present");
1508 assert_eq!(entry_ids(&c.line.expect("line").segments), vec!["model"]);
1509 }
1510
1511 #[test]
1512 fn load_returns_none_for_missing_file() {
1513 let dir = tempdir();
1514 let path = dir.path().join("nonexistent.toml");
1515 assert!(Config::load(&path).unwrap().is_none());
1516 }
1517
1518 fn resolved(
1521 cli: Option<&str>,
1522 env: Option<&str>,
1523 xdg: Option<&str>,
1524 home: Option<&str>,
1525 ) -> Option<ConfigPath> {
1526 resolve_config_path(
1527 cli.map(PathBuf::from),
1528 env.map(std::ffi::OsStr::new),
1529 xdg.map(std::ffi::OsStr::new),
1530 home.map(std::ffi::OsStr::new),
1531 )
1532 }
1533
1534 #[test]
1535 fn cli_override_wins_over_everything_and_is_explicit() {
1536 let got = resolved(
1537 Some("/explicit.toml"),
1538 Some("/env.toml"),
1539 Some("/xdg"),
1540 Some("/home"),
1541 )
1542 .expect("resolved");
1543 assert_eq!(got.path, PathBuf::from("/explicit.toml"));
1544 assert!(got.explicit);
1545 }
1546
1547 #[test]
1548 fn env_wins_over_xdg_and_home_and_is_explicit() {
1549 let got = resolved(None, Some("/env.toml"), Some("/xdg"), Some("/home")).expect("resolved");
1550 assert_eq!(got.path, PathBuf::from("/env.toml"));
1551 assert!(got.explicit);
1552 }
1553
1554 #[test]
1555 fn xdg_config_home_is_implicit() {
1556 let got = resolved(None, None, Some("/xdg"), Some("/home")).expect("resolved");
1557 assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1558 assert!(!got.explicit);
1559 }
1560
1561 #[test]
1562 fn home_fallback_is_implicit() {
1563 let got = resolved(None, None, None, Some("/home")).expect("resolved");
1564 assert_eq!(
1565 got.path,
1566 PathBuf::from("/home/.config/linesmith/config.toml")
1567 );
1568 assert!(!got.explicit);
1569 }
1570
1571 #[test]
1572 fn returns_none_when_no_home_and_no_xdg() {
1573 assert_eq!(resolved(None, None, None, None), None);
1574 }
1575
1576 #[test]
1577 fn empty_env_values_are_ignored() {
1578 let got = resolved(None, Some(""), Some(""), Some("/home")).expect("resolved");
1579 assert_eq!(
1580 got.path,
1581 PathBuf::from("/home/.config/linesmith/config.toml")
1582 );
1583 }
1584
1585 #[test]
1586 fn empty_cli_override_does_not_count_as_explicit() {
1587 let got = resolved(Some(""), None, Some("/xdg"), None).expect("resolved");
1591 assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
1592 assert!(!got.explicit);
1593 }
1594
1595 struct TempDir(PathBuf);
1598
1599 impl TempDir {
1600 fn path(&self) -> &Path {
1601 &self.0
1602 }
1603 }
1604
1605 impl Drop for TempDir {
1606 fn drop(&mut self) {
1607 let _ = std::fs::remove_dir_all(&self.0);
1608 }
1609 }
1610
1611 fn tempdir() -> TempDir {
1612 use std::sync::atomic::{AtomicU64, Ordering};
1613 static COUNTER: AtomicU64 = AtomicU64::new(0);
1614 let base = std::env::temp_dir().join(format!(
1615 "linesmith-config-test-{}-{}",
1616 std::time::SystemTime::now()
1617 .duration_since(std::time::UNIX_EPOCH)
1618 .expect("clock")
1619 .as_nanos(),
1620 COUNTER.fetch_add(1, Ordering::Relaxed),
1621 ));
1622 std::fs::create_dir_all(&base).expect("mkdir");
1623 TempDir(base)
1624 }
1625}