use schemars::JsonSchema;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::str::FromStr;
#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
#[serde(default)]
#[schemars(extend("additionalProperties" = false))]
pub struct Config {
pub line: Option<LineConfig>,
pub theme: Option<String>,
pub layout: LayoutMode,
pub layout_options: Option<LayoutOptions>,
#[serde(default)]
pub segments: BTreeMap<String, SegmentOverride>,
#[serde(default)]
pub plugin_dirs: Vec<PathBuf>,
pub preset: Option<String>,
#[serde(default)]
#[schemars(with = "Option<BTreeMap<String, serde_json::Value>>")]
pub plugins: Option<BTreeMap<String, toml::Value>>,
#[serde(default, rename = "$schema")]
pub schema_url: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(default)]
#[non_exhaustive]
#[schemars(extend("additionalProperties" = false))]
pub struct LayoutOptions {
pub color: ColorPolicy,
pub claude_padding: u16,
pub separator: Option<String>,
pub powerline_width: Option<u16>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ColorPolicy {
#[default]
Auto,
Always,
Never,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
#[serde(default)]
pub struct LineConfig {
#[serde(deserialize_with = "deserialize_line_entries")]
pub segments: Vec<LineEntry>,
#[serde(flatten)]
#[schemars(with = "serde_json::Value")]
pub numbered: BTreeMap<String, toml::Value>,
}
#[derive(Debug, Clone, PartialEq, Deserialize, JsonSchema)]
#[serde(untagged)]
pub enum LineEntry {
Id(String),
Item(LineEntryItem),
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
#[serde(default)]
pub struct LineEntryItem {
#[serde(rename = "type")]
pub kind: Option<String>,
pub character: Option<String>,
pub merge: Option<bool>,
#[serde(flatten)]
#[schemars(with = "serde_json::Value")]
pub extra: BTreeMap<String, toml::Value>,
}
impl LineEntry {
#[must_use]
pub fn kind(&self) -> Option<&str> {
match self {
Self::Id(s) => Some(s.as_str()),
Self::Item(item) => item.kind.as_deref(),
}
}
#[must_use]
pub fn is_separator(&self) -> bool {
self.kind() == Some("separator")
}
#[must_use]
pub fn segment_id(&self) -> Option<&str> {
match self.kind() {
Some("separator") | None => None,
Some(id) => Some(id),
}
}
#[must_use]
pub fn separator_character(&self) -> Option<&str> {
match self {
Self::Item(item) if item.kind.as_deref() == Some("separator") => {
item.character.as_deref()
}
_ => None,
}
}
#[must_use]
pub fn merge(&self) -> bool {
match self {
Self::Item(item) if item.kind.as_deref() != Some("separator") => {
item.merge.unwrap_or(false)
}
_ => false,
}
}
}
impl From<&str> for LineEntry {
fn from(s: &str) -> Self {
Self::Id(s.to_string())
}
}
impl From<String> for LineEntry {
fn from(s: String) -> Self {
Self::Id(s)
}
}
fn deserialize_line_entries<'de, D>(deserializer: D) -> Result<Vec<LineEntry>, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = Vec::<toml::Value>::deserialize(deserializer)?;
Ok(raw.into_iter().map(value_to_line_entry).collect())
}
fn value_to_line_entry(value: toml::Value) -> LineEntry {
if let toml::Value::String(s) = &value {
return LineEntry::Id(s.clone());
}
if let toml::Value::Table(_) = &value {
if let Ok(item) = value.clone().try_into::<LineEntryItem>() {
return LineEntry::Item(item);
}
}
let mut extra: BTreeMap<String, toml::Value> = BTreeMap::new();
if let toml::Value::Table(table) = value {
for (k, v) in table {
extra.insert(k, v);
}
} else {
extra.insert("__malformed__".to_string(), value);
}
LineEntry::Item(LineEntryItem {
kind: None,
character: None,
merge: None,
extra,
})
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[serde(rename_all = "kebab-case")]
#[non_exhaustive]
pub enum LayoutMode {
#[default]
SingleLine,
MultiLine,
}
#[derive(Debug, Default, Clone, PartialEq, Deserialize, JsonSchema)]
#[serde(default)]
pub struct SegmentOverride {
pub priority: Option<u8>,
pub width: Option<WidthBoundsConfig>,
pub style: Option<String>,
#[serde(flatten)]
#[schemars(with = "serde_json::Value")]
pub extra: BTreeMap<String, toml::Value>,
}
pub const SCHEMA_URL: &str =
"https://raw.githubusercontent.com/oakoss/linesmith/main/config.schema.json";
pub fn with_schema_directive(body: &str) -> String {
format!("#:schema {SCHEMA_URL}\n\n{body}")
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, JsonSchema)]
#[schemars(extend("additionalProperties" = false))]
pub struct WidthBoundsConfig {
pub min: Option<u16>,
pub max: Option<u16>,
}
#[derive(Debug)]
#[non_exhaustive]
pub enum ConfigError {
Io {
path: PathBuf,
source: std::io::Error,
},
Parse {
path: Option<PathBuf>,
source: toml::de::Error,
},
}
impl std::fmt::Display for ConfigError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io { path, source } => write!(f, "config I/O at {}: {source}", path.display()),
Self::Parse {
path: Some(p),
source,
} => write!(f, "config parse at {}: {source}", p.display()),
Self::Parse { path: None, source } => write!(f, "config parse: {source}"),
}
}
}
impl std::error::Error for ConfigError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io { source, .. } => Some(source),
Self::Parse { source, .. } => Some(source),
}
}
}
impl FromStr for Config {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
toml::from_str(s).map_err(|source| ConfigError::Parse { path: None, source })
}
}
impl Config {
pub fn load(path: &Path) -> Result<Option<Self>, ConfigError> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(ConfigError::Io {
path: path.to_owned(),
source,
})
}
};
toml::from_str(&raw)
.map(Some)
.map_err(|source| ConfigError::Parse {
path: Some(path.to_owned()),
source,
})
}
pub fn load_validated(
path: &Path,
warn: impl FnMut(&str),
) -> Result<Option<Self>, ConfigError> {
let raw = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(ConfigError::Io {
path: path.to_owned(),
source,
})
}
};
Self::from_str_validated_impl(&raw, Some(path), warn).map(Some)
}
pub fn from_str_validated(s: &str, warn: impl FnMut(&str)) -> Result<Self, ConfigError> {
Self::from_str_validated_impl(s, None, warn)
}
fn from_str_validated_impl(
s: &str,
path: Option<&Path>,
mut warn: impl FnMut(&str),
) -> Result<Self, ConfigError> {
let raw: toml::Value = toml::from_str(s).map_err(|source| ConfigError::Parse {
path: path.map(Path::to_owned),
source,
})?;
validate_keys(&raw, &mut warn);
raw.try_into()
.map_err(|source: toml::de::Error| ConfigError::Parse {
path: path.map(Path::to_owned),
source,
})
}
}
const KNOWN_TOP_LEVEL: &[&str] = &[
"line",
"theme",
"layout_options",
"segments",
"plugin_dirs",
"preset",
"layout",
"plugins",
"$schema",
];
const KNOWN_LAYOUT_OPTIONS: &[&str] = &["color", "claude_padding", "separator", "powerline_width"];
fn segment_override_schema(id: &str) -> Option<&'static [&'static str]> {
const BUILT_IN_COMMON: &[&str] = &["priority", "width", "style", "visible_if"];
const RATE_LIMIT_COMMON: &[&str] = &[
"priority",
"width",
"style",
"visible_if",
"icon",
"label",
"stale_marker",
"progress_width",
"format",
];
const PERCENT_SEGMENT: &[&str] = &[
"priority",
"width",
"style",
"visible_if",
"icon",
"label",
"stale_marker",
"progress_width",
"format",
"invert",
];
const RESET_SEGMENT: &[&str] = &[
"priority",
"width",
"style",
"visible_if",
"icon",
"label",
"stale_marker",
"progress_width",
"format",
"compact",
"use_days",
"timezone",
"hour_format",
"locale",
];
const GIT_BRANCH_SEGMENT: &[&str] = &[
"priority",
"width",
"style",
"visible_if",
"icon",
"label",
"max_length",
"truncation_marker",
"short_sha_length",
"dirty",
"ahead_behind",
];
const MODEL_SEGMENT: &[&str] = &["priority", "width", "style", "visible_if", "format"];
match id {
"model" => Some(MODEL_SEGMENT),
"workspace" | "cost" | "effort" | "context_window" => Some(BUILT_IN_COMMON),
"rate_limit_5h" | "rate_limit_7d" => Some(PERCENT_SEGMENT),
"rate_limit_5h_reset" | "rate_limit_7d_reset" => Some(RESET_SEGMENT),
"extra_usage" => Some(RATE_LIMIT_COMMON),
"git_branch" => Some(GIT_BRANCH_SEGMENT),
_ => None,
}
}
fn validate_keys(raw: &toml::Value, warn: &mut impl FnMut(&str)) {
let Some(top) = raw.as_table() else {
return;
};
for (key, value) in top {
if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
warn(&format!("unknown top-level config key '{key}'; ignoring"));
continue;
}
match key.as_str() {
"layout_options" => {
validate_flat_table(value, "layout_options", KNOWN_LAYOUT_OPTIONS, warn)
}
"segments" => validate_segments_table(value, warn),
_ => {}
}
}
}
fn validate_flat_table(
value: &toml::Value,
label: &str,
allowed: &[&str],
warn: &mut impl FnMut(&str),
) {
let Some(table) = value.as_table() else {
return;
};
for key in table.keys() {
if !allowed.contains(&key.as_str()) {
warn(&format!("unknown key '{key}' in [{label}]; ignoring"));
}
}
}
fn validate_segments_table(value: &toml::Value, warn: &mut impl FnMut(&str)) {
let Some(segments) = value.as_table() else {
return;
};
for (id, block) in segments {
let Some(block_table) = block.as_table() else {
continue;
};
let Some(allowed) = segment_override_schema(id) else {
continue;
};
for key in block_table.keys() {
if !allowed.contains(&key.as_str()) {
warn(&format!("unknown key '{key}' in [segments.{id}]; ignoring"));
}
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ConfigPath {
pub path: PathBuf,
pub explicit: bool,
}
#[must_use]
pub fn resolve_config_path(
cli_override: Option<PathBuf>,
env_override: Option<&std::ffi::OsStr>,
xdg_config_home: Option<&std::ffi::OsStr>,
home: Option<&std::ffi::OsStr>,
) -> Option<ConfigPath> {
if let Some(p) = cli_override.filter(|p| !p.as_os_str().is_empty()) {
return Some(ConfigPath {
path: p,
explicit: true,
});
}
if let Some(p) = env_override.filter(|s| !s.is_empty()) {
return Some(ConfigPath {
path: PathBuf::from(p),
explicit: true,
});
}
if let Some(p) = xdg_config_home.filter(|s| !s.is_empty()) {
return Some(ConfigPath {
path: PathBuf::from(p).join("linesmith").join("config.toml"),
explicit: false,
});
}
home.filter(|s| !s.is_empty()).map(|h| ConfigPath {
path: PathBuf::from(h).join(".config/linesmith/config.toml"),
explicit: false,
})
}
#[must_use]
pub fn detect_config_path(cli_override: Option<PathBuf>) -> Option<ConfigPath> {
let env_override = std::env::var_os("LINESMITH_CONFIG");
let xdg_config_home = std::env::var_os("XDG_CONFIG_HOME");
let home = std::env::var_os("HOME");
resolve_config_path(
cli_override,
env_override.as_deref(),
xdg_config_home.as_deref(),
home.as_deref(),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn empty_config_parses() {
let c = Config::from_str("").expect("parse ok");
assert_eq!(c.line, None);
assert!(c.segments.is_empty());
}
#[test]
fn line_segments_parse_in_order() {
let c = Config::from_str(
r#"
[line]
segments = ["model", "workspace", "cost"]
"#,
)
.expect("parse ok");
let line = c.line.expect("line present");
assert_eq!(
entry_ids(&line.segments),
vec!["model", "workspace", "cost"]
);
assert!(line.numbered.is_empty(), "no numbered tables expected");
}
#[test]
fn layout_field_defaults_to_single_line_when_omitted() {
let c = Config::from_str("").expect("parse ok");
assert_eq!(c.layout, LayoutMode::SingleLine);
}
#[test]
fn layout_field_parses_kebab_case_variants() {
let c = Config::from_str(r#"layout = "single-line""#).expect("parse ok");
assert_eq!(c.layout, LayoutMode::SingleLine);
let c = Config::from_str(r#"layout = "multi-line""#).expect("parse ok");
assert_eq!(c.layout, LayoutMode::MultiLine);
}
fn numbered_segments(value: &toml::Value) -> Vec<String> {
let table = value.as_table().expect("expected table value");
let array = table["segments"]
.as_array()
.expect("expected segments array");
array
.iter()
.map(|v| v.as_str().expect("expected string").to_string())
.collect()
}
fn entry_ids(entries: &[LineEntry]) -> Vec<&str> {
entries.iter().filter_map(LineEntry::segment_id).collect()
}
#[test]
fn line_numbered_only_parses() {
let c = Config::from_str(
r#"
[line.1]
segments = ["model"]
[line.2]
segments = ["workspace", "cost"]
"#,
)
.expect("parse ok");
let line = c.line.expect("line present");
assert!(
line.segments.is_empty(),
"no top-level segments key expected"
);
assert_eq!(line.numbered.len(), 2);
assert_eq!(numbered_segments(&line.numbered["1"]), vec!["model"]);
assert_eq!(
numbered_segments(&line.numbered["2"]),
vec!["workspace", "cost"]
);
}
#[test]
fn line_with_segments_and_numbered_children_coexist() {
let c = Config::from_str(
r#"
[line]
segments = ["fallback"]
[line.1]
segments = ["a", "b"]
[line.2]
segments = ["c"]
"#,
)
.expect("parse ok");
let line = c.line.expect("line present");
assert_eq!(entry_ids(&line.segments), vec!["fallback"]);
assert_eq!(line.numbered.len(), 2);
assert_eq!(numbered_segments(&line.numbered["1"]), vec!["a", "b"]);
assert_eq!(numbered_segments(&line.numbered["2"]), vec!["c"]);
}
#[test]
fn line_numbered_keys_preserved_verbatim_for_builder_validation() {
let c = Config::from_str(
r#"
[line.foo]
segments = ["bogus"]
[line.10]
segments = ["valid"]
"#,
)
.expect("parse ok");
let line = c.line.expect("line present");
assert_eq!(line.numbered.len(), 2);
assert!(line.numbered.contains_key("foo"));
assert!(line.numbered.contains_key("10"));
}
#[test]
fn line_unknown_scalar_key_does_not_fail_parse_forward_compat() {
let c = Config::from_str(
r#"
[line]
segments = ["model"]
segmnts = ["typo"] # scalar / array
future_separator = " | " # scalar string
[line.1]
segments = ["valid"]
"#,
)
.expect("parse ok despite unknown sibling keys");
let line = c.line.expect("line present");
assert_eq!(entry_ids(&line.segments), vec!["model"]);
assert!(line.numbered.contains_key("segmnts"));
assert!(line.numbered.contains_key("future_separator"));
assert!(line.numbered.contains_key("1"));
}
#[test]
fn segment_override_priority_parses() {
let c = Config::from_str(
r#"
[segments.model]
priority = 16
"#,
)
.expect("parse ok");
assert_eq!(c.segments["model"].priority, Some(16));
assert_eq!(c.segments["model"].width, None);
}
#[test]
fn layout_options_color_and_padding_parse() {
let c = Config::from_str(
r#"
[layout_options]
color = "always"
claude_padding = 3
"#,
)
.expect("parse ok");
let lo = c.layout_options.expect("layout_options present");
assert_eq!(lo.color, ColorPolicy::Always);
assert_eq!(lo.claude_padding, 3);
}
#[test]
fn layout_options_color_accepts_all_three_variants() {
for (toml_val, expected) in [
("auto", ColorPolicy::Auto),
("always", ColorPolicy::Always),
("never", ColorPolicy::Never),
] {
let src = format!("[layout_options]\ncolor = \"{toml_val}\"\n");
let c = Config::from_str(&src).expect("parse ok");
assert_eq!(c.layout_options.map(|l| l.color), Some(expected));
}
}
fn collect_warnings(src: &str) -> Vec<String> {
let mut warnings = Vec::new();
let _ = Config::from_str_validated(src, |msg| warnings.push(msg.to_string()));
warnings
}
#[test]
fn plugin_dirs_deserializes_from_toml_as_path_list() {
let cfg: Config = Config::from_str(
r#"
plugin_dirs = ["/etc/linesmith/segments", "./vendor/plugins"]
[line]
segments = ["model"]
"#,
)
.expect("parse");
assert_eq!(
cfg.plugin_dirs,
vec![
PathBuf::from("/etc/linesmith/segments"),
PathBuf::from("./vendor/plugins"),
]
);
}
#[test]
fn plugin_dirs_defaults_to_empty_when_absent() {
let cfg: Config = Config::from_str("theme = \"default\"\n").expect("parse");
assert!(cfg.plugin_dirs.is_empty());
}
#[test]
fn from_str_validated_warns_on_unknown_top_level_key() {
let warnings = collect_warnings("thme = \"oops\"\n[line]\nsegments = []\n");
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("thme"));
assert!(warnings[0].contains("top-level"));
}
#[test]
fn from_str_validated_allows_implemented_and_forward_compat_top_level_keys() {
let toml = r#"
"$schema" = "https://example.invalid/schema.json"
theme = "default"
preset = "developer"
layout = "single-line"
[line]
segments = ["model"]
[layout_options]
color = "auto"
[plugins.example]
foo = "bar"
"#;
let warnings = collect_warnings(toml);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let cfg = Config::from_str(toml).expect("parses");
assert_eq!(cfg.preset.as_deref(), Some("developer"));
assert_eq!(
cfg.schema_url.as_deref(),
Some("https://example.invalid/schema.json")
);
let plugins = cfg.plugins.expect("plugins table populated");
assert!(plugins.contains_key("example"));
}
#[test]
fn schema_for_config_round_trips_as_valid_json() {
let schema = schemars::schema_for!(Config);
let json = serde_json::to_string(&schema).expect("schema serializes as JSON");
let parsed: serde_json::Value =
serde_json::from_str(&json).expect("schema round-trips as JSON");
let obj = parsed.as_object().expect("schema root is an object");
assert_eq!(
obj.get("$schema").and_then(|v| v.as_str()),
Some("https://json-schema.org/draft/2020-12/schema"),
"schema must declare its meta-schema URI"
);
assert_eq!(
obj.get("title").and_then(|v| v.as_str()),
Some("Config"),
"schema must title the root type"
);
let properties = obj
.get("properties")
.and_then(|v| v.as_object())
.expect("schema declares properties");
for key in ["preset", "plugins", "$schema"] {
assert!(
properties.contains_key(key),
"schema must expose {key:?} as a top-level property"
);
}
}
#[test]
fn schema_directive_wrapped_body_round_trips_as_toml() {
let body = "[line]\nsegments = [\"model\"]\n";
let wrapped = with_schema_directive(body);
assert!(
wrapped.starts_with("#:schema https://"),
"directive at byte 0"
);
assert!(
wrapped.contains("\n\n["),
"blank-line separator before first table"
);
let parsed: Config = wrapped.parse().expect("wrapped body parses as Config");
assert_eq!(
entry_ids(&parsed.line.expect("line").segments),
vec!["model"]
);
}
#[test]
fn from_str_validated_warns_on_unknown_layout_options_key() {
let warnings = collect_warnings(
r#"
[layout_options]
separatr = "powerline"
"#,
);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("separatr"));
assert!(warnings[0].contains("[layout_options]"));
}
#[test]
fn from_str_validated_allows_separator_and_other_known_layout_options_keys() {
let warnings = collect_warnings(
r#"
[layout_options]
color = "always"
claude_padding = 2
separator = "powerline"
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn from_str_validated_warns_on_unknown_segment_override_key() {
let warnings = collect_warnings(
r#"
[segments.model]
priorty = 16
"#,
);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("priorty"));
assert!(warnings[0].contains("[segments.model]"));
}
#[test]
fn from_str_validated_names_the_segment_id_in_warnings() {
let warnings = collect_warnings(
r#"
[segments.workspace]
bogus = "x"
[segments.cost]
alsobogus = 1
"#,
);
assert_eq!(warnings.len(), 2);
assert!(warnings
.iter()
.any(|w| w.contains("[segments.workspace]") && w.contains("bogus")));
assert!(warnings
.iter()
.any(|w| w.contains("[segments.cost]") && w.contains("alsobogus")));
}
#[test]
fn from_str_validated_skips_unknown_segment_ids_because_plugins_own_their_schema() {
let warnings = collect_warnings(
r#"
[segments.my_plugin]
foo = "bar"
baz = 42
[segments.another_plugin]
show_ahead_behind = true
show_dirty = true
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn from_str_validated_rejects_segment_specific_keys_on_wrong_built_in() {
let warnings = collect_warnings(
r#"
[segments.model]
show_dirty = true
"#,
);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("show_dirty"));
assert!(warnings[0].contains("[segments.model]"));
}
#[test]
fn from_str_validated_allows_spec_documented_segment_override_keys() {
let warnings = collect_warnings(
r#"
[segments.workspace]
priority = 16
width = { min = 10, max = 40 }
style = "role:info"
visible_if = "true"
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn reset_segment_allows_absolute_format_keys_without_warning() {
let warnings = collect_warnings(
r#"
[segments.rate_limit_5h_reset]
format = "absolute"
timezone = "America/Los_Angeles"
hour_format = "12h"
locale = "en-US"
[segments.rate_limit_7d_reset]
format = "absolute"
timezone = "Europe/London"
hour_format = "24h"
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn model_segment_allows_format_key_without_warning() {
let warnings = collect_warnings(
r#"
[segments.model]
format = "compact"
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
let warnings_full = collect_warnings(
r#"
[segments.model]
format = "full"
"#,
);
assert!(
warnings_full.is_empty(),
"unexpected warnings: {warnings_full:?}"
);
}
#[test]
fn workspace_segment_warns_when_format_key_set() {
let warnings = collect_warnings(
r#"
[segments.workspace]
format = "compact"
"#,
);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("format"));
assert!(warnings[0].contains("[segments.workspace]"));
}
#[test]
fn git_branch_allows_per_marker_hide_below_cells_without_warning() {
let warnings = collect_warnings(
r#"
[segments.git_branch.dirty]
hide_below_cells = 50
[segments.git_branch.ahead_behind]
hide_below_cells = 80
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn rate_limit_percent_segments_allow_format_and_invert_without_warning() {
let warnings = collect_warnings(
r#"
[segments.rate_limit_5h]
format = "progress"
invert = true
icon = "⏱"
label = "5h"
stale_marker = "~"
progress_width = 20
[segments.rate_limit_7d]
format = "percent"
invert = false
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn rate_limit_reset_segments_allow_compact_and_use_days_without_warning() {
let warnings = collect_warnings(
r#"
[segments.rate_limit_5h_reset]
format = "duration"
compact = true
use_days = false
[segments.rate_limit_7d_reset]
format = "progress"
use_days = true
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn extra_usage_allows_currency_and_percent_format_without_warning() {
let warnings = collect_warnings(
r#"
[segments.extra_usage]
format = "currency"
icon = ""
label = "extra"
stale_marker = "~"
"#,
);
assert!(warnings.is_empty(), "unexpected warnings: {warnings:?}");
}
#[test]
fn invert_warns_on_reset_segment_schema() {
let warnings = collect_warnings(
r#"
[segments.rate_limit_5h_reset]
invert = true
"#,
);
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].contains("invert") && warnings[0].contains("rate_limit_5h_reset"),
"{:?}",
warnings[0]
);
}
#[test]
fn use_days_warns_on_percent_segment_schema() {
let warnings = collect_warnings(
r#"
[segments.rate_limit_5h]
use_days = true
"#,
);
assert_eq!(warnings.len(), 1);
assert!(
warnings[0].contains("use_days") && warnings[0].contains("rate_limit_5h"),
"{:?}",
warnings[0]
);
}
#[test]
fn from_str_validated_returns_parse_error_for_malformed_toml() {
let mut warnings = Vec::new();
let err =
Config::from_str_validated("[line\nsegments =", |msg| warnings.push(msg.to_string()))
.unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn validated_and_silent_parse_yield_identical_config_on_clean_input() {
let src = r#"
theme = "default"
[line]
segments = ["model", "workspace"]
[segments.model]
priority = 8
"#;
let silent = Config::from_str(src).expect("silent parse");
let validated = Config::from_str_validated(src, |_| {}).expect("validated parse");
assert_eq!(silent, validated);
}
#[test]
fn load_validated_file_path_surfaces_parse_error_with_path() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "[line\nsegments =").unwrap();
let err = Config::load_validated(&path, |_| {}).unwrap_err();
match err {
ConfigError::Parse { path: Some(p), .. } => assert_eq!(p, path),
other => panic!("expected Parse with Some(path), got {other:?}"),
}
}
#[test]
fn load_validated_returns_none_for_missing_file() {
let dir = tempdir();
let path = dir.path().join("missing.toml");
let mut warnings = Vec::new();
let got = Config::load_validated(&path, |m| warnings.push(m.to_string())).expect("ok");
assert!(got.is_none());
assert!(warnings.is_empty());
}
#[test]
fn load_validated_surfaces_unknown_key_warnings() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "thme = \"bad\"\n").unwrap();
let mut warnings = Vec::new();
let _ = Config::load_validated(&path, |m| warnings.push(m.to_string())).unwrap();
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("thme"));
}
#[test]
fn layout_options_defaults_populate_missing_keys() {
let c = Config::from_str("[layout_options]\n").expect("parse ok");
let lo = c.layout_options.expect("layout_options present");
assert_eq!(lo.color, ColorPolicy::Auto);
assert_eq!(lo.claude_padding, 0);
}
#[test]
fn layout_options_rejects_unknown_color_variant() {
let err = Config::from_str(
r#"
[layout_options]
color = "bogus"
"#,
)
.unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn layout_options_omitted_entirely_is_ok() {
let c = Config::from_str("[line]\nsegments = [\"model\"]\n").expect("parse ok");
assert!(c.layout_options.is_none());
}
#[test]
fn segment_override_width_parses_both_sides() {
let c = Config::from_str(
r#"
[segments.workspace.width]
min = 10
max = 40
"#,
)
.expect("parse ok");
let w = c.segments["workspace"].width.expect("width present");
assert_eq!(w.min, Some(10));
assert_eq!(w.max, Some(40));
}
#[test]
fn unknown_top_level_key_is_forward_compatible() {
let c = Config::from_str(
r#"
theme = "catppuccin-mocha"
layout = "single-line"
[layout_options]
separator = "powerline"
"#,
)
.expect("parse ok");
assert_eq!(c.line, None);
assert!(c.segments.is_empty());
}
#[test]
fn malformed_toml_reports_parse_error() {
let err = Config::from_str("[line").unwrap_err();
assert!(matches!(err, ConfigError::Parse { .. }));
}
#[test]
fn io_error_carries_path_in_display() {
use std::io::ErrorKind;
let err = ConfigError::Io {
path: PathBuf::from("/etc/linesmith/config.toml"),
source: std::io::Error::new(ErrorKind::PermissionDenied, "denied"),
};
let rendered = err.to_string();
assert!(rendered.contains("/etc/linesmith/config.toml"));
assert!(rendered.contains("denied"));
}
#[test]
fn bom_prefixed_config_parses() {
let dir = tempdir();
let path = dir.path().join("config.toml");
std::fs::write(&path, "\u{FEFF}[line]\nsegments = [\"model\"]\n").unwrap();
let c = Config::load(&path).expect("ok").expect("present");
assert_eq!(entry_ids(&c.line.expect("line").segments), vec!["model"]);
}
#[test]
fn load_returns_none_for_missing_file() {
let dir = tempdir();
let path = dir.path().join("nonexistent.toml");
assert!(Config::load(&path).unwrap().is_none());
}
fn resolved(
cli: Option<&str>,
env: Option<&str>,
xdg: Option<&str>,
home: Option<&str>,
) -> Option<ConfigPath> {
resolve_config_path(
cli.map(PathBuf::from),
env.map(std::ffi::OsStr::new),
xdg.map(std::ffi::OsStr::new),
home.map(std::ffi::OsStr::new),
)
}
#[test]
fn cli_override_wins_over_everything_and_is_explicit() {
let got = resolved(
Some("/explicit.toml"),
Some("/env.toml"),
Some("/xdg"),
Some("/home"),
)
.expect("resolved");
assert_eq!(got.path, PathBuf::from("/explicit.toml"));
assert!(got.explicit);
}
#[test]
fn env_wins_over_xdg_and_home_and_is_explicit() {
let got = resolved(None, Some("/env.toml"), Some("/xdg"), Some("/home")).expect("resolved");
assert_eq!(got.path, PathBuf::from("/env.toml"));
assert!(got.explicit);
}
#[test]
fn xdg_config_home_is_implicit() {
let got = resolved(None, None, Some("/xdg"), Some("/home")).expect("resolved");
assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
assert!(!got.explicit);
}
#[test]
fn home_fallback_is_implicit() {
let got = resolved(None, None, None, Some("/home")).expect("resolved");
assert_eq!(
got.path,
PathBuf::from("/home/.config/linesmith/config.toml")
);
assert!(!got.explicit);
}
#[test]
fn returns_none_when_no_home_and_no_xdg() {
assert_eq!(resolved(None, None, None, None), None);
}
#[test]
fn empty_env_values_are_ignored() {
let got = resolved(None, Some(""), Some(""), Some("/home")).expect("resolved");
assert_eq!(
got.path,
PathBuf::from("/home/.config/linesmith/config.toml")
);
}
#[test]
fn empty_cli_override_does_not_count_as_explicit() {
let got = resolved(Some(""), None, Some("/xdg"), None).expect("resolved");
assert_eq!(got.path, PathBuf::from("/xdg/linesmith/config.toml"));
assert!(!got.explicit);
}
struct TempDir(PathBuf);
impl TempDir {
fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn tempdir() -> TempDir {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let base = std::env::temp_dir().join(format!(
"linesmith-config-test-{}-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos(),
COUNTER.fetch_add(1, Ordering::Relaxed),
));
std::fs::create_dir_all(&base).expect("mkdir");
TempDir(base)
}
}