use serde::Deserialize;
#[derive(Debug, Deserialize, Default)]
pub struct CshipConfig {
pub lines: Option<Vec<String>>,
pub format: Option<String>,
pub model: Option<ModelConfig>,
pub cost: Option<CostConfig>,
pub context_bar: Option<ContextBarConfig>,
pub context_window: Option<ContextWindowConfig>,
pub vim: Option<VimConfig>,
pub agent: Option<AgentConfig>,
pub session: Option<SessionConfig>,
pub workspace: Option<WorkspaceConfig>,
pub usage_limits: Option<UsageLimitsConfig>,
pub peak_usage: Option<PeakUsageConfig>,
pub starship_prompt: Option<StarshipPromptConfig>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ModelConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<bool>,
pub format: Option<String>,
pub haiku_style: Option<String>,
pub sonnet_style: Option<String>,
pub opus_style: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct CostConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub warn_threshold: Option<f64>,
pub warn_style: Option<String>,
pub critical_threshold: Option<f64>,
pub critical_style: Option<String>,
pub format: Option<String>,
pub currency_symbol: Option<String>,
pub conversion_rate: Option<f64>,
pub total_cost_usd: Option<SubfieldConfig>,
pub total_duration_ms: Option<SubfieldConfig>,
pub total_api_duration_ms: Option<SubfieldConfig>,
pub total_lines_added: Option<SubfieldConfig>,
pub total_lines_removed: Option<SubfieldConfig>,
}
#[derive(Debug, Deserialize, Default)]
pub struct SubfieldConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub warn_threshold: Option<f64>,
pub warn_style: Option<String>,
pub critical_threshold: Option<f64>,
pub critical_style: Option<String>,
pub format: Option<String>,
pub invert_threshold: Option<bool>,
}
#[cfg(test)]
pub type CostSubfieldConfig = SubfieldConfig;
#[cfg(test)]
pub type ContextWindowSubfieldConfig = SubfieldConfig;
pub trait HasThresholdStyle {
fn style(&self) -> Option<&str>;
fn warn_threshold(&self) -> Option<f64>;
fn warn_style(&self) -> Option<&str>;
fn critical_threshold(&self) -> Option<f64>;
fn critical_style(&self) -> Option<&str>;
fn format_str(&self) -> Option<&str> {
None
}
fn symbol_str(&self) -> Option<&str> {
None
}
}
#[allow(unused_macros)]
macro_rules! impl_has_threshold_style {
($t:ty) => {
impl HasThresholdStyle for $t {
fn style(&self) -> Option<&str> {
self.style.as_deref()
}
fn warn_threshold(&self) -> Option<f64> {
self.warn_threshold
}
fn warn_style(&self) -> Option<&str> {
self.warn_style.as_deref()
}
fn critical_threshold(&self) -> Option<f64> {
self.critical_threshold
}
fn critical_style(&self) -> Option<&str> {
self.critical_style.as_deref()
}
}
};
}
#[derive(Debug, Deserialize, Default)]
pub struct ContextBarConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub warn_threshold: Option<f64>,
pub warn_style: Option<String>,
pub critical_threshold: Option<f64>,
pub critical_style: Option<String>,
pub width: Option<u32>,
pub format: Option<String>,
pub empty_style: Option<String>,
pub filled_char: Option<String>,
pub empty_char: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct ContextWindowConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub warn_threshold: Option<f64>,
pub warn_style: Option<String>,
pub critical_threshold: Option<f64>,
pub critical_style: Option<String>,
pub format: Option<String>,
pub used_percentage: Option<SubfieldConfig>,
pub remaining_percentage: Option<SubfieldConfig>,
pub size: Option<SubfieldConfig>,
pub total_input_tokens: Option<SubfieldConfig>,
pub total_output_tokens: Option<SubfieldConfig>,
pub current_usage_input_tokens: Option<SubfieldConfig>,
pub current_usage_output_tokens: Option<SubfieldConfig>,
pub current_usage_cache_creation_input_tokens: Option<SubfieldConfig>,
pub current_usage_cache_read_input_tokens: Option<SubfieldConfig>,
pub used_tokens: Option<SubfieldConfig>,
}
impl HasThresholdStyle for ContextWindowConfig {
fn style(&self) -> Option<&str> {
self.style.as_deref()
}
fn warn_threshold(&self) -> Option<f64> {
self.warn_threshold
}
fn warn_style(&self) -> Option<&str> {
self.warn_style.as_deref()
}
fn critical_threshold(&self) -> Option<f64> {
self.critical_threshold
}
fn critical_style(&self) -> Option<&str> {
self.critical_style.as_deref()
}
fn format_str(&self) -> Option<&str> {
self.format.as_deref()
}
fn symbol_str(&self) -> Option<&str> {
self.symbol.as_deref()
}
}
#[derive(Debug, Deserialize, Default)]
pub struct VimConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub normal_style: Option<String>,
pub insert_style: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct AgentConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct SessionConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct WorkspaceConfig {
pub style: Option<String>,
pub symbol: Option<String>,
pub disabled: Option<bool>,
pub label: Option<String>,
pub format: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct UsageLimitsConfig {
pub disabled: Option<bool>,
pub style: Option<String>,
pub warn_threshold: Option<f64>,
pub warn_style: Option<String>,
pub critical_threshold: Option<f64>,
pub critical_style: Option<String>,
pub ttl: Option<u64>,
pub format: Option<String>,
pub five_hour_format: Option<String>,
pub seven_day_format: Option<String>,
pub separator: Option<String>,
pub show_per_model: Option<bool>,
pub extra_usage_format: Option<String>,
pub opus_format: Option<String>,
pub sonnet_format: Option<String>,
pub cowork_format: Option<String>,
pub oauth_apps_format: Option<String>,
}
#[derive(Debug, Deserialize, Default)]
pub struct PeakUsageConfig {
pub disabled: Option<bool>,
pub symbol: Option<String>,
pub style: Option<String>,
pub format: Option<String>,
pub start_hour: Option<u32>,
pub end_hour: Option<u32>,
}
#[derive(Debug, Deserialize, Default)]
pub struct StarshipPromptConfig {
pub disabled: Option<bool>,
}
pub struct ConfigLoadResult {
pub config: CshipConfig,
pub source: ConfigSource,
}
pub enum ConfigSource {
ProjectLocal(std::path::PathBuf),
Global(std::path::PathBuf),
Override(std::path::PathBuf),
DedicatedFile(std::path::PathBuf),
Default,
}
impl std::fmt::Display for ConfigSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
ConfigSource::ProjectLocal(p) | ConfigSource::Global(p) | ConfigSource::Override(p) => {
write!(f, "{}", p.display())
}
ConfigSource::DedicatedFile(p) => write!(f, "{} (cship.toml)", p.display()),
ConfigSource::Default => write!(f, "(default — no config file found)"),
}
}
}
pub fn load_with_source(
override_path: Option<&std::path::Path>,
workspace_dir: Option<&str>,
) -> ConfigLoadResult {
if let Some(path) = override_path {
let config = load_override(path).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load config from {}: {e}", path.display());
CshipConfig::default()
});
return ConfigLoadResult {
config,
source: if path.file_name().map(|n| n == "cship.toml").unwrap_or(false) {
ConfigSource::DedicatedFile(path.to_path_buf())
} else {
ConfigSource::Override(path.to_path_buf())
},
};
}
if let Some(dir) = workspace_dir {
let mut current = std::path::Path::new(dir);
loop {
let cship_candidate = current.join("cship.toml");
if cship_candidate.exists() {
let config = load_cship_toml(&cship_candidate).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load dedicated config: {e}");
CshipConfig::default()
});
return ConfigLoadResult {
config,
source: ConfigSource::DedicatedFile(cship_candidate),
};
}
let candidate = current.join("starship.toml");
if candidate.exists() {
let config = load_from_path(&candidate).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load project-local config: {e}");
CshipConfig::default()
});
return ConfigLoadResult {
config,
source: ConfigSource::ProjectLocal(candidate),
};
}
match current.parent() {
Some(parent) => current = parent,
None => break,
}
}
}
if let Some(home) = crate::platform::home_dir() {
let cship_global = home.join(".config").join("cship.toml");
if cship_global.exists() {
let config = load_cship_toml(&cship_global).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load global dedicated config: {e}");
CshipConfig::default()
});
return ConfigLoadResult {
config,
source: ConfigSource::DedicatedFile(cship_global),
};
}
let global = home.join(".config").join("starship.toml");
if global.exists() {
let config = load_from_path(&global).unwrap_or_else(|e| {
tracing::warn!("cship: failed to load global config: {e}");
CshipConfig::default()
});
return ConfigLoadResult {
config,
source: ConfigSource::Global(global),
};
}
}
tracing::debug!("no config file found; using default CshipConfig");
ConfigLoadResult {
config: CshipConfig::default(),
source: ConfigSource::Default,
}
}
#[derive(Debug, Deserialize, Default)]
struct StarshipToml {
cship: Option<CshipConfig>,
}
fn load_from_path(path: &std::path::Path) -> anyhow::Result<CshipConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read config file {}: {e}", path.display()))?;
let wrapper: StarshipToml = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("malformed TOML in {}: {e}", path.display()))?;
tracing::debug!("loaded config from {}", path.display());
Ok(wrapper.cship.unwrap_or_default())
}
fn load_cship_toml(path: &std::path::Path) -> anyhow::Result<CshipConfig> {
let content = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("cannot read config file {}: {e}", path.display()))?;
let value: toml::Value = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("malformed TOML in {}: {e}", path.display()))?;
if value.get("cship").is_some() {
tracing::debug!("loading cship.toml with [cship] section via wrapper");
let wrapper: StarshipToml = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("malformed TOML in {}: {e}", path.display()))?;
tracing::debug!("loaded config from {} (via wrapper)", path.display());
return Ok(wrapper.cship.unwrap_or_default());
}
tracing::debug!("loading cship.toml in legacy wrapper-free format");
let config: CshipConfig = toml::from_str(&content)
.map_err(|e| anyhow::anyhow!("malformed TOML in {}: {e}", path.display()))?;
tracing::debug!("loaded config from {}", path.display());
Ok(config)
}
fn load_override(path: &std::path::Path) -> anyhow::Result<CshipConfig> {
if path.file_name().map(|n| n == "cship.toml").unwrap_or(false) {
load_cship_toml(path)
} else {
load_from_path(path)
}
}
pub fn discover_and_load(
workspace_dir: Option<&str>,
config_path: Option<&str>,
) -> anyhow::Result<CshipConfig> {
if let Some(path) = config_path {
return load_override(std::path::Path::new(path));
}
Ok(load_with_source(None, workspace_dir).config)
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
const VALID_TOML: &str = include_str!("../tests/fixtures/sample_starship.toml");
#[test]
fn test_parse_valid_config() {
let wrapper: StarshipToml = toml::from_str(VALID_TOML).unwrap();
let cfg = wrapper.cship.unwrap();
let lines = cfg.lines.as_ref().unwrap();
assert!(!lines.is_empty(), "lines should be populated");
let model = cfg.model.as_ref().unwrap();
assert!(model.style.is_some(), "model.style should be present");
assert_eq!(model.disabled, Some(false));
}
#[test]
fn test_no_cship_section_returns_default() {
let toml_without_cship = "[git_branch]\nstyle = \"bold green\"\n";
let wrapper: StarshipToml = toml::from_str(toml_without_cship).unwrap();
assert!(wrapper.cship.is_none());
let cfg = wrapper.cship.unwrap_or_default();
assert!(cfg.lines.is_none());
assert!(cfg.model.is_none());
}
#[test]
fn test_malformed_toml_returns_error() {
let result: Result<StarshipToml, _> = toml::from_str("lines = [unclosed");
assert!(result.is_err());
}
#[test]
fn test_load_from_nonexistent_path_returns_error() {
let result = load_from_path(std::path::Path::new("/nonexistent/path/starship.toml"));
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("cannot read config file"),
"error message: {msg}"
);
}
#[test]
fn test_discover_config_override_bypasses_discovery() {
let dir = std::env::temp_dir();
let path = dir.join("cship_test_override.toml");
let mut f = std::fs::File::create(&path).unwrap();
writeln!(f, "[cship]\nlines = [\"$cship.model\"]").unwrap();
let cfg = discover_and_load(None, path.to_str()).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.model");
std::fs::remove_file(&path).ok();
}
#[test]
fn test_discover_walks_up_directory_tree() {
let parent = std::env::temp_dir().join("cship_test_walk");
let subdir = parent.join("subdir");
std::fs::create_dir_all(&subdir).unwrap();
let toml_path = parent.join("starship.toml");
let mut f = std::fs::File::create(&toml_path).unwrap();
writeln!(f, "[cship]\nlines = [\"$cship.model\"]").unwrap();
let cfg = discover_and_load(subdir.to_str(), None).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.model");
std::fs::remove_dir_all(&parent).ok();
}
#[test]
fn test_cship_toml_takes_priority_over_starship_toml() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(f, "lines = [\"$cship.cost\"]").unwrap();
let starship_path = dir.path().join("starship.toml");
let mut g = std::fs::File::create(&starship_path).unwrap();
writeln!(g, "[cship]\nlines = [\"$cship.model\"]").unwrap();
let cfg = discover_and_load(dir.path().to_str(), None).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.cost");
}
#[test]
fn test_cship_toml_absent_falls_through_to_starship_toml() {
let dir = tempfile::tempdir().unwrap();
let starship_path = dir.path().join("starship.toml");
let mut f = std::fs::File::create(&starship_path).unwrap();
writeln!(f, "[cship]\nlines = [\"$cship.model\"]").unwrap();
let cfg = discover_and_load(dir.path().to_str(), None).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.model");
}
#[test]
fn test_cship_toml_walk_up_from_subdirectory() {
let parent = tempfile::tempdir().unwrap();
let subdir = parent.path().join("child");
std::fs::create_dir_all(&subdir).unwrap();
let cship_path = parent.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(f, "lines = [\"$cship.workspace\"]").unwrap();
let cfg = discover_and_load(subdir.to_str(), None).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.workspace");
}
#[test]
fn test_cship_toml_with_wrapper_section_still_parses() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(f, "[cship]\nlines = [\"$cship.agent\"]").unwrap();
let cfg = load_cship_toml(&cship_path).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.agent");
}
#[test]
fn test_cship_toml_canonical_format_loads_correctly() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(
f,
"[cship]\nlines = [\"$cship.model $cship.cost $cship.context_bar\"]"
)
.unwrap();
let cfg = load_cship_toml(&cship_path).unwrap();
assert_eq!(
cfg.lines.as_ref().unwrap()[0],
"$cship.model $cship.cost $cship.context_bar"
);
}
#[test]
fn test_cship_toml_direct_cshipconfig_parsing() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(
f,
"lines = [\"$cship.model\"]\n\n[model]\nstyle = \"bold green\""
)
.unwrap();
let cfg = load_cship_toml(&cship_path).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.model");
assert_eq!(
cfg.model.as_ref().unwrap().style.as_deref(),
Some("bold green")
);
}
#[test]
fn test_dedicated_file_config_source_display() {
let path = std::path::PathBuf::from("/home/user/.config/cship.toml");
let source = ConfigSource::DedicatedFile(path);
let display = source.to_string();
assert!(display.contains("cship.toml"), "display: {display}");
assert!(
display.contains("(cship.toml)"),
"should have annotation: {display}"
);
}
#[test]
fn test_override_cship_toml_routes_correctly() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(f, "lines = [\"$cship.session\"]").unwrap();
let cfg = discover_and_load(None, cship_path.to_str()).unwrap();
assert_eq!(cfg.lines.as_ref().unwrap()[0], "$cship.session");
}
#[test]
fn test_load_with_source_returns_dedicated_file_variant() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(f, "lines = [\"$cship.cost\"]").unwrap();
let result = load_with_source(None, dir.path().to_str());
assert!(
matches!(result.source, ConfigSource::DedicatedFile(_)),
"expected DedicatedFile source, got: {}",
result.source
);
assert_eq!(result.config.lines.as_ref().unwrap()[0], "$cship.cost");
}
#[test]
fn test_load_with_source_override_cship_toml_returns_dedicated_file_variant() {
let dir = tempfile::tempdir().unwrap();
let cship_path = dir.path().join("cship.toml");
let mut f = std::fs::File::create(&cship_path).unwrap();
writeln!(f, "lines = [\"$cship.cost\"]").unwrap();
let result = load_with_source(Some(&cship_path), None);
assert!(
matches!(result.source, ConfigSource::DedicatedFile(_)),
"expected DedicatedFile source for cship.toml override, got: {}",
result.source
);
assert_eq!(result.config.lines.as_ref().unwrap()[0], "$cship.cost");
}
}