use minijinja::Value;
use serde::Deserialize;
pub const BLUE: &str = "\x1b[34m";
pub const YELLOW: &str = "\x1b[33m";
pub const GREEN: &str = "\x1b[32m";
pub const RED: &str = "\x1b[31m";
pub const MAGENTA: &str = "\x1b[35m";
pub const GRAY: &str = "\x1b[90m";
pub const RESET: &str = "\x1b[0m";
fn colored(value: &str, color: &str) -> String {
if value.is_empty() {
String::new()
} else {
format!("{color}{value}{RESET}")
}
}
fn format_tokens(tokens: u64) -> String {
if tokens >= 1_000_000 {
format!("{:.1}M", tokens as f64 / 1_000_000.0)
} else if tokens >= 1_000 {
format!("{}k", tokens / 1_000)
} else {
tokens.to_string()
}
}
pub fn budget_color(pct: f64) -> &'static str {
if pct > 95.0 {
RED
} else if pct >= 80.0 {
YELLOW
} else {
GREEN
}
}
#[derive(Deserialize, Default)]
pub struct Config {
pub format: Option<FormatConfig>,
pub budget: Option<BudgetConfig>,
}
#[derive(Deserialize)]
pub struct FormatConfig {
pub template: Option<String>,
}
#[derive(Deserialize)]
pub struct BudgetConfig {
pub monthly_limit: Option<f64>,
}
pub const DEFAULT_TEMPLATE: &str = "\
{{ blue }}{{ path_raw }}{{ reset }}\
{% if repo_owner_raw and repo_name_raw %} {{ yellow }}{{ repo_owner_raw }}/{{ repo_name_raw }}\
{% if branch_raw %}:{{ branch_raw }}{% endif %}{{ reset }}{% elif branch_raw %} {{ yellow }}{{ branch_raw }}{{ reset }}\
{% endif %}\
{% if worktree_raw %} {{ blue }}[wt:{{ worktree_raw }}]{{ reset }}{% endif %}\
{% if pr_number_raw %} {{ magenta }}[PR#{{ pr_number_raw }}{{ pr_state_raw }}]{{ reset }}{% endif %}\
{% if session_name_raw %} {{ gray }}|{{ reset }} {{ magenta }}{{ session_name_raw }}{{ reset }}{% endif %}\n\
{{ green }}{{ model_raw }}{{ reset }}\
{% if context_total_raw and context_pct_raw %} {{ gray }}[{{ context_used_raw }}k/{{ context_total_raw }}k ({{ context_pct_raw }}% used)]{{ reset }}{% endif %}\n\
{% if cost_raw %}{{ yellow }}${{ cost_raw }}{{ reset }}{% if budget_limit_raw %} {{ gray }}(${{ monthly_cost_raw }}/${{ budget_limit_raw }}){{ reset }}{% elif monthly_cost_raw %} {{ gray }}(${{ monthly_cost_raw }}/mo){{ reset }}{% endif %}{% endif %}\
{% if duration_raw %}{% if cost_raw %} {{ gray }}·{{ reset }}{% endif %} {{ gray }}{{ duration_raw }}{{ reset }}{% endif %}\
{% if lines_added_raw or lines_removed_raw %}{% if cost_raw or duration_raw %} {{ gray }}·{{ reset }}{% endif %} {{ green }}+{{ lines_added_raw }}{{ reset }}{{ gray }}/{{ reset }}{{ red }}-{{ lines_removed_raw }}{{ reset }} {{ gray }}lines{{ reset }}{% endif %}";
pub fn config_path() -> String {
let config_dir = std::env::var("XDG_CONFIG_HOME").unwrap_or_else(|_| {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
format!("{home}/.config")
});
format!("{config_dir}/paddington/config.toml")
}
pub fn load_config() -> (Config, Option<String>) {
load_config_from_path(&config_path())
}
pub fn load_config_from_path(path: &str) -> (Config, Option<String>) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return (Config::default(), None),
};
match toml::from_str(&content) {
Ok(c) => (c, None),
Err(e) => (Config::default(), Some(format!("TOML parse error: {e}"))),
}
}
pub fn resolve_budget_limit(config: &Config) -> Option<f64> {
config
.budget
.as_ref()
.and_then(|b| b.monthly_limit)
.filter(|&v| v > 0.0)
}
pub fn build_context(
input: &crate::Input,
monthly_total: Option<f64>,
branch: &str,
git_dirty: bool,
budget_limit: Option<f64>,
) -> Value {
let model_raw = input
.model
.as_ref()
.and_then(|m| m.display_name.as_deref())
.unwrap_or("Claude");
let model_id_raw = input
.model
.as_ref()
.and_then(|m| m.id.as_deref())
.unwrap_or("");
let repo_owner_raw = input
.workspace
.as_ref()
.and_then(|w| w.repo.as_ref())
.and_then(|r| r.owner.as_deref())
.unwrap_or("");
let repo_name_raw = input
.workspace
.as_ref()
.and_then(|w| w.repo.as_ref())
.and_then(|r| r.name.as_deref())
.unwrap_or("");
let project_dir = input
.workspace
.as_ref()
.and_then(|w| w.project_dir.as_deref())
.unwrap_or("");
let cwd = input.cwd.as_deref().unwrap_or("");
let path_raw = if !project_dir.is_empty() && cwd.starts_with(project_dir) {
let rel = &cwd[project_dir.len()..];
let base = std::path::Path::new(project_dir)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(project_dir);
if rel.is_empty() {
base.to_string()
} else {
format!("{base}{rel}")
}
} else {
let home = std::env::var("HOME").unwrap_or_default();
if !home.is_empty() && cwd.starts_with(&home) {
format!("~{}", &cwd[home.len()..])
} else {
cwd.to_string()
}
};
let worktree_raw = input
.worktree
.as_ref()
.and_then(|w| w.name.as_deref())
.unwrap_or("");
let session_name_raw = input.session_name.as_deref().unwrap_or("");
let (pr_number_raw, pr_state_raw) = match &input.pr {
Some(pr) => {
let num = pr
.number
.as_ref()
.map(|n| match n {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
_ => String::new(),
})
.unwrap_or_default();
let state = match pr.review_state.as_deref() {
Some("approved") => "✓",
Some("changes_requested") => "✗",
Some("draft") => "~",
_ => "",
};
(num, state.to_string())
}
None => (String::new(), String::new()),
};
let (context_used_raw, context_total_raw, context_pct_raw) = match &input.context_window {
Some(ctx) if ctx.context_window_size.unwrap_or(0) > 0 && ctx.used_percentage.is_some() => {
let input_tok = ctx.total_input_tokens.unwrap_or(0);
let output_tok = ctx.total_output_tokens.unwrap_or(0);
let size = ctx.context_window_size.unwrap_or(0);
let used_k = (input_tok + output_tok) / 1000;
let max_k = size / 1000;
let pct = format!("{:.0}", ctx.used_percentage.unwrap());
(used_k.to_string(), max_k.to_string(), pct)
}
_ => (String::new(), String::new(), String::new()),
};
let (cost_raw, duration_raw, lines_added_raw, lines_removed_raw) = match &input.cost {
Some(cost) => {
let c = cost
.total_cost_usd
.map(|v| format!("{v:.2}"))
.unwrap_or_default();
let d = cost
.total_duration_ms
.map(crate::format_duration)
.unwrap_or_default();
let la = cost
.total_lines_added
.map(|v| v.to_string())
.unwrap_or_default();
let lr = cost
.total_lines_removed
.map(|v| v.to_string())
.unwrap_or_default();
(c, d, la, lr)
}
None => (String::new(), String::new(), String::new(), String::new()),
};
let monthly_cost_raw = monthly_total.map(|v| format!("{v:.2}")).unwrap_or_default();
let (budget_limit_raw, budget_remaining_raw, budget_pct_raw, budget_color_code) =
match (budget_limit, monthly_total) {
(Some(limit), Some(total)) => {
let pct = (total / limit * 100.0).round();
let remaining = (limit - total).max(0.0);
(
format!("{limit:.2}"),
format!("{remaining:.2}"),
format!("{pct:.0}"),
budget_color(pct),
)
}
(Some(limit), None) => (
format!("{limit:.2}"),
format!("{limit:.2}"),
"0".to_string(),
GREEN,
),
_ => (String::new(), String::new(), String::new(), GREEN),
};
let project_name_raw = input
.workspace
.as_ref()
.and_then(|w| w.project_dir.as_deref())
.and_then(|p| std::path::Path::new(p).file_name())
.and_then(|n| n.to_str())
.unwrap_or("");
let session_id_raw = input.session_id.as_deref().unwrap_or("");
let hostname_raw = std::env::var("HOSTNAME")
.or_else(|_| std::fs::read_to_string("/etc/hostname").map(|s| s.trim().to_string()))
.unwrap_or_default();
let username_raw = std::env::var("USER").unwrap_or_default();
let datetime_raw = chrono::Local::now().format("%H:%M").to_string();
let paddington_version_raw = env!("CARGO_PKG_VERSION");
let git_dirty_raw = if git_dirty { "dirty" } else { "" };
let provider_raw = input.provider.as_deref().unwrap_or("");
let reasoning_level_raw = input.reasoning_level.as_deref().unwrap_or("");
let agent_raw = input.agent.as_deref().unwrap_or_else(|| {
if std::env::var("PI_CODING_AGENT").is_ok() {
"pi"
} else if std::env::var("CLAUDE_CODE").is_ok()
|| std::env::var("CLAUDE_CODE_ENTRYPOINT").is_ok()
{
"claude-code"
} else {
""
}
});
let is_pi = agent_raw == "pi";
let (tokens_input_raw, tokens_output_raw) = match &input.tokens {
Some(tokens) => (
tokens.input.map(format_tokens).unwrap_or_default(),
tokens.output.map(format_tokens).unwrap_or_default(),
),
None => (String::new(), String::new()),
};
let (cache_read_raw, cache_write_raw, cache_hit_rate_raw) = match &input.cache {
Some(cache) => (
cache.read_tokens.map(format_tokens).unwrap_or_default(),
cache.write_tokens.map(format_tokens).unwrap_or_default(),
cache
.hit_rate
.map(|v| format!("{v:.0}"))
.unwrap_or_default(),
),
None => (String::new(), String::new(), String::new()),
};
minijinja::context! {
blue => BLUE,
yellow => YELLOW,
green => GREEN,
red => RED,
magenta => MAGENTA,
gray => GRAY,
reset => RESET,
path_raw => path_raw,
repo_owner_raw => repo_owner_raw,
repo_name_raw => repo_name_raw,
branch_raw => branch,
worktree_raw => worktree_raw,
pr_number_raw => pr_number_raw,
pr_state_raw => pr_state_raw,
session_name_raw => session_name_raw,
model_raw => model_raw,
context_used_raw => context_used_raw,
context_total_raw => context_total_raw,
context_pct_raw => context_pct_raw,
cost_raw => cost_raw,
monthly_cost_raw => monthly_cost_raw,
duration_raw => duration_raw,
lines_added_raw => lines_added_raw,
lines_removed_raw => lines_removed_raw,
model_id_raw => model_id_raw,
project_name_raw => project_name_raw,
session_id_raw => session_id_raw,
hostname_raw => hostname_raw,
username_raw => username_raw,
datetime_raw => datetime_raw,
paddington_version_raw => paddington_version_raw,
git_dirty_raw => git_dirty_raw,
provider_raw => provider_raw,
reasoning_level_raw => reasoning_level_raw,
agent_raw => agent_raw,
is_pi => is_pi,
cache_read_raw => cache_read_raw,
cache_write_raw => cache_write_raw,
cache_hit_rate_raw => cache_hit_rate_raw,
tokens_input_raw => tokens_input_raw,
tokens_output_raw => tokens_output_raw,
budget_limit_raw => budget_limit_raw,
budget_remaining_raw => budget_remaining_raw,
budget_pct_raw => budget_pct_raw,
path => colored(&path_raw, BLUE),
repo_owner => colored(repo_owner_raw, YELLOW),
repo_name => colored(repo_name_raw, YELLOW),
branch => colored(branch, YELLOW),
worktree => colored(worktree_raw, BLUE),
pr_number => colored(&pr_number_raw, MAGENTA),
pr_state => colored(&pr_state_raw, MAGENTA),
session_name => colored(session_name_raw, MAGENTA),
model => colored(model_raw, GREEN),
context_used => colored(&context_used_raw, GRAY),
context_total => colored(&context_total_raw, GRAY),
context_pct => colored(&context_pct_raw, GRAY),
cost => colored(&cost_raw, YELLOW),
monthly_cost => colored(&monthly_cost_raw, YELLOW),
duration => colored(&duration_raw, GRAY),
lines_added => colored(&lines_added_raw, GREEN),
lines_removed => colored(&lines_removed_raw, RED),
model_id => colored(model_id_raw, GRAY),
project_name => colored(project_name_raw, BLUE),
session_id => colored(session_id_raw, GRAY),
hostname => colored(&hostname_raw, GRAY),
username => colored(&username_raw, GRAY),
datetime => colored(&datetime_raw, GRAY),
paddington_version => colored(paddington_version_raw, GRAY),
git_dirty => colored(git_dirty_raw, YELLOW),
provider => colored(provider_raw, GRAY),
reasoning_level => colored(reasoning_level_raw, MAGENTA),
agent => colored(agent_raw, GRAY),
cache_read => colored(&cache_read_raw, GRAY),
cache_write => colored(&cache_write_raw, GRAY),
cache_hit_rate => colored(&cache_hit_rate_raw, GRAY),
tokens_input => colored(&tokens_input_raw, GRAY),
tokens_output => colored(&tokens_output_raw, GRAY),
budget_limit => colored(&budget_limit_raw, GRAY),
budget_remaining => colored(&budget_remaining_raw, budget_color_code),
budget_pct => colored(&budget_pct_raw, budget_color_code),
}
}
pub fn render_template(template_str: &str, ctx: &Value) -> Result<String, String> {
let env = minijinja::Environment::new();
let tmpl = env
.template_from_str(template_str)
.map_err(|e| format!("template syntax: {e}"))?;
tmpl.render(ctx)
.map_err(|e| format!("template render: {e}"))
}
pub fn render_error_line(msg: &str) -> String {
format!("{RED}[paddington: {msg}]{RESET}")
}
pub fn mock_context() -> Value {
let input = crate::Input {
cwd: Some("/home/user/code/paddington".into()),
model: Some(crate::Model {
id: Some("claude-opus-4-6".into()),
display_name: Some("Opus 4.6 (1M context)".into()),
}),
workspace: Some(crate::Workspace {
project_dir: Some("/home/user/code/paddington".into()),
repo: Some(crate::Repo {
owner: Some("cebarks".into()),
name: Some("paddington".into()),
}),
}),
worktree: None,
pr: Some(crate::PullRequest {
number: Some(serde_json::Value::Number(7.into())),
review_state: Some("approved".into()),
}),
session_id: Some("mock-session".into()),
session_name: Some("template-work".into()),
context_window: Some(crate::ContextWindow {
total_input_tokens: Some(230_000),
total_output_tokens: Some(26_000),
context_window_size: Some(1_000_000),
used_percentage: Some(26.0),
}),
cost: Some(crate::Cost {
total_cost_usd: Some(1.50),
total_duration_ms: Some(754_000),
total_lines_added: Some(128),
total_lines_removed: Some(42),
}),
provider: Some("anthropic".into()),
reasoning_level: Some("high".into()),
agent: Some("pi".into()),
cache: Some(crate::CacheStats {
read_tokens: Some(145_000),
write_tokens: Some(23_000),
hit_rate: Some(87.0),
}),
tokens: Some(crate::TokenStats {
input: Some(230_000),
output: Some(26_000),
}),
git_branch_override: None,
};
build_context(&input, Some(42.00), "feature/templates", true, Some(500.0))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_template_returns_default_when_no_file() {
let (config, err) = load_config_from_path("/tmp/paddington-test-nonexistent/config.toml");
let template = config
.format
.and_then(|f| f.template)
.unwrap_or_else(|| DEFAULT_TEMPLATE.to_string());
assert_eq!(template, DEFAULT_TEMPLATE);
assert!(err.is_none());
}
#[test]
fn load_template_parses_valid_toml() {
let dir = std::env::temp_dir().join("paddington-test-valid");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[format]
template = "{{ model }} {{ cost }}"
"#,
)
.unwrap();
let (config, err) = load_config_from_path(path.to_str().unwrap());
let template = config
.format
.and_then(|f| f.template)
.unwrap_or_else(|| DEFAULT_TEMPLATE.to_string());
assert_eq!(template, "{{ model }} {{ cost }}");
assert!(err.is_none());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_template_returns_error_on_bad_toml() {
let dir = std::env::temp_dir().join("paddington-test-bad-toml");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(&path, "not valid toml [[[").unwrap();
let (config, err) = load_config_from_path(path.to_str().unwrap());
let template = config
.format
.and_then(|f| f.template)
.unwrap_or_else(|| DEFAULT_TEMPLATE.to_string());
assert_eq!(template, DEFAULT_TEMPLATE);
assert!(err.is_some());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_context_includes_colored_and_raw_variants() {
let input = crate::Input {
model: Some(crate::Model {
display_name: Some("Opus 4.6".into()),
..Default::default()
}),
cost: Some(crate::Cost {
total_cost_usd: Some(1.5),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, None, "", false, None);
assert_eq!(ctx.get_attr("model_raw").unwrap().to_string(), "Opus 4.6");
let model_colored = ctx.get_attr("model").unwrap().to_string();
assert!(model_colored.contains("\x1b[32m"));
assert!(model_colored.contains("Opus 4.6"));
assert!(model_colored.contains("\x1b[0m"));
assert_eq!(ctx.get_attr("cost_raw").unwrap().to_string(), "1.50");
}
#[test]
fn build_context_missing_fields_are_empty() {
let input = crate::Input::default();
let ctx = build_context(&input, None, "", false, None);
assert_eq!(ctx.get_attr("cost_raw").unwrap().to_string(), "");
assert_eq!(ctx.get_attr("branch_raw").unwrap().to_string(), "");
assert_eq!(ctx.get_attr("model_raw").unwrap().to_string(), "Claude");
}
#[test]
fn render_template_substitutes_variables() {
let input = crate::Input {
model: Some(crate::Model {
display_name: Some("Sonnet 5".into()),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, None, "", false, None);
let result = render_template("Model: {{ model_raw }}", &ctx).unwrap();
assert_eq!(result, "Model: Sonnet 5");
}
#[test]
fn render_template_returns_error_on_bad_syntax() {
let input = crate::Input::default();
let ctx = build_context(&input, None, "", false, None);
let result = render_template("{{ unclosed", &ctx);
assert!(result.is_err());
}
#[test]
fn render_error_line_contains_message() {
let line = render_error_line("bad toml");
assert!(line.contains("bad toml"));
assert!(line.contains("\x1b[31m")); assert!(line.contains("paddington"));
}
#[test]
fn mock_context_has_all_fields() {
let ctx = mock_context();
assert_eq!(
ctx.get_attr("model_raw").unwrap().to_string(),
"Opus 4.6 (1M context)"
);
assert_eq!(ctx.get_attr("cost_raw").unwrap().to_string(), "1.50");
assert_eq!(
ctx.get_attr("repo_owner_raw").unwrap().to_string(),
"cebarks"
);
assert_eq!(ctx.get_attr("pr_number_raw").unwrap().to_string(), "7");
assert_eq!(
ctx.get_attr("provider_raw").unwrap().to_string(),
"anthropic"
);
assert_eq!(
ctx.get_attr("reasoning_level_raw").unwrap().to_string(),
"high"
);
assert_eq!(ctx.get_attr("agent_raw").unwrap().to_string(), "pi");
assert_eq!(ctx.get_attr("cache_read_raw").unwrap().to_string(), "145k");
assert_eq!(ctx.get_attr("cache_write_raw").unwrap().to_string(), "23k");
assert_eq!(
ctx.get_attr("cache_hit_rate_raw").unwrap().to_string(),
"87"
);
assert_eq!(ctx.get_attr("blue").unwrap().to_string(), "\x1b[34m");
}
#[test]
fn default_template_matches_hardcoded_format() {
let input = crate::Input {
cwd: Some("/home/user/code/paddington/src".into()),
model: Some(crate::Model {
id: Some("claude-opus-4-6".into()),
display_name: Some("Opus 4.6".into()),
}),
workspace: Some(crate::Workspace {
project_dir: Some("/home/user/code/paddington".into()),
repo: Some(crate::Repo {
owner: Some("cebarks".into()),
name: Some("paddington".into()),
}),
}),
worktree: None,
pr: None,
session_id: None,
session_name: None,
context_window: Some(crate::ContextWindow {
total_input_tokens: Some(128_000),
total_output_tokens: Some(5_000),
context_window_size: Some(200_000),
used_percentage: Some(66.0),
}),
cost: Some(crate::Cost {
total_cost_usd: Some(1.50),
total_duration_ms: Some(754_000),
total_lines_added: Some(128),
total_lines_removed: Some(42),
}),
..Default::default()
};
let ctx = build_context(&input, Some(42.00), "main", false, Some(500.0));
let rendered = render_template(DEFAULT_TEMPLATE, &ctx).unwrap();
assert!(rendered.contains("\x1b[34mpaddington/src\x1b[0m"));
assert!(rendered.contains("\x1b[33mcebarks/paddington:main\x1b[0m"));
assert!(rendered.contains("\x1b[32mOpus 4.6\x1b[0m"));
assert!(rendered.contains("133k/200k"));
assert!(rendered.contains("66% used"));
assert!(rendered.contains("$1.50"));
assert!(rendered.contains("$42.00/$500.00"));
assert!(rendered.contains("+128"));
assert!(rendered.contains("-42"));
}
#[test]
fn default_template_no_cost_still_shows_duration_and_lines() {
let input = crate::Input {
cost: Some(crate::Cost {
total_cost_usd: None,
total_duration_ms: Some(60_000),
total_lines_added: Some(10),
total_lines_removed: Some(5),
}),
..Default::default()
};
let ctx = build_context(&input, None, "", false, None);
let rendered = render_template(DEFAULT_TEMPLATE, &ctx).unwrap();
assert!(
rendered.contains("1m 0s"),
"duration should render when cost_usd is None"
);
assert!(
rendered.contains("+10"),
"lines_added should render when cost_usd is None"
);
assert!(
rendered.contains("-5"),
"lines_removed should render when cost_usd is None"
);
assert!(
!rendered.contains("$"),
"no dollar sign when cost_usd is None"
);
}
#[test]
fn default_template_context_window_without_percentage() {
let input = crate::Input {
context_window: Some(crate::ContextWindow {
total_input_tokens: Some(100_000),
total_output_tokens: Some(5_000),
context_window_size: Some(200_000),
used_percentage: None,
}),
..Default::default()
};
let ctx = build_context(&input, None, "", false, None);
let rendered = render_template(DEFAULT_TEMPLATE, &ctx).unwrap();
assert!(
!rendered.contains("k/"),
"context window should not render without used_percentage"
);
}
#[test]
fn default_template_zero_lines_still_renders() {
let input = crate::Input {
cost: Some(crate::Cost {
total_cost_usd: Some(1.0),
total_lines_added: Some(0),
total_lines_removed: Some(0),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, None, "", false, None);
let rendered = render_template(DEFAULT_TEMPLATE, &ctx).unwrap();
assert!(rendered.contains("+0"), "zero lines_added should render");
assert!(rendered.contains("-0"), "zero lines_removed should render");
}
#[test]
fn pi_specific_fields_render_in_template() {
let input = crate::Input {
model: Some(crate::Model {
display_name: Some("Sonnet 4".into()),
..Default::default()
}),
provider: Some("anthropic".into()),
reasoning_level: Some("high".into()),
agent: Some("pi".into()),
cache: Some(crate::CacheStats {
read_tokens: Some(1_500_000),
write_tokens: Some(500),
hit_rate: Some(92.0),
}),
..Default::default()
};
let ctx = build_context(&input, None, "", false, None);
let result = render_template(
"{{ model_raw }} ({{ provider_raw }}) [{{ reasoning_level_raw }}] via {{ agent_raw }} R{{ cache_read_raw }} W{{ cache_write_raw }} {{ cache_hit_rate_raw }}%",
&ctx,
)
.unwrap();
assert_eq!(result, "Sonnet 4 (anthropic) [high] via pi R1.5M W500 92%");
}
#[test]
fn is_pi_flag_works() {
let pi_input = crate::Input {
agent: Some("pi".into()),
..Default::default()
};
let ctx = build_context(&pi_input, None, "", false, None);
assert_eq!(ctx.get_attr("is_pi").unwrap().to_string(), "true");
let cc_input = crate::Input {
agent: Some("claude-code".into()),
..Default::default()
};
let ctx = build_context(&cc_input, None, "", false, None);
assert_eq!(ctx.get_attr("is_pi").unwrap().to_string(), "false");
}
#[test]
fn load_config_parses_budget_section() {
let dir = std::env::temp_dir().join("paddington-test-budget");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[budget]
monthly_limit = 500.0
"#,
)
.unwrap();
let (config, err) = load_config_from_path(path.to_str().unwrap());
assert!(err.is_none());
assert_eq!(resolve_budget_limit(&config), Some(500.0));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_config_no_budget_section() {
let dir = std::env::temp_dir().join("paddington-test-no-budget");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[format]
template = "{{ model }}"
"#,
)
.unwrap();
let (config, err) = load_config_from_path(path.to_str().unwrap());
assert!(err.is_none());
assert_eq!(resolve_budget_limit(&config), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_config_budget_zero_treated_as_absent() {
let dir = std::env::temp_dir().join("paddington-test-budget-zero");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[budget]
monthly_limit = 0
"#,
)
.unwrap();
let (config, err) = load_config_from_path(path.to_str().unwrap());
assert!(err.is_none());
assert_eq!(resolve_budget_limit(&config), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn load_config_budget_negative_treated_as_absent() {
let dir = std::env::temp_dir().join("paddington-test-budget-neg");
let _ = std::fs::create_dir_all(&dir);
let path = dir.join("config.toml");
std::fs::write(
&path,
r#"
[budget]
monthly_limit = -100
"#,
)
.unwrap();
let (config, err) = load_config_from_path(path.to_str().unwrap());
assert!(err.is_none());
assert_eq!(resolve_budget_limit(&config), None);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn build_context_budget_under_80_pct_is_green() {
let input = crate::Input {
cost: Some(crate::Cost {
total_cost_usd: Some(1.0),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, Some(100.0), "", false, Some(500.0));
assert_eq!(
ctx.get_attr("budget_limit_raw").unwrap().to_string(),
"500.00"
);
assert_eq!(
ctx.get_attr("budget_remaining_raw").unwrap().to_string(),
"400.00"
);
assert_eq!(ctx.get_attr("budget_pct_raw").unwrap().to_string(), "20");
let limit_colored = ctx.get_attr("budget_limit").unwrap().to_string();
assert!(limit_colored.contains("\x1b[90m")); let remaining_colored = ctx.get_attr("budget_remaining").unwrap().to_string();
assert!(remaining_colored.contains("\x1b[32m")); let pct_colored = ctx.get_attr("budget_pct").unwrap().to_string();
assert!(pct_colored.contains("\x1b[32m")); }
#[test]
fn build_context_budget_between_80_and_95_pct_is_yellow() {
let input = crate::Input {
cost: Some(crate::Cost {
total_cost_usd: Some(1.0),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, Some(425.0), "", false, Some(500.0));
assert_eq!(ctx.get_attr("budget_pct_raw").unwrap().to_string(), "85");
let remaining_colored = ctx.get_attr("budget_remaining").unwrap().to_string();
assert!(remaining_colored.contains("\x1b[33m")); }
#[test]
fn build_context_budget_over_95_pct_is_red() {
let input = crate::Input {
cost: Some(crate::Cost {
total_cost_usd: Some(1.0),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, Some(490.0), "", false, Some(500.0));
assert_eq!(ctx.get_attr("budget_pct_raw").unwrap().to_string(), "98");
let remaining_colored = ctx.get_attr("budget_remaining").unwrap().to_string();
assert!(remaining_colored.contains("\x1b[31m")); }
#[test]
fn build_context_budget_over_100_pct_uncapped() {
let input = crate::Input {
cost: Some(crate::Cost {
total_cost_usd: Some(1.0),
..Default::default()
}),
..Default::default()
};
let ctx = build_context(&input, Some(600.0), "", false, Some(500.0));
assert_eq!(ctx.get_attr("budget_pct_raw").unwrap().to_string(), "120");
assert_eq!(
ctx.get_attr("budget_remaining_raw").unwrap().to_string(),
"0.00"
);
}
#[test]
fn build_context_no_budget_empty_variables() {
let input = crate::Input::default();
let ctx = build_context(&input, None, "", false, None);
assert_eq!(ctx.get_attr("budget_limit_raw").unwrap().to_string(), "");
assert_eq!(
ctx.get_attr("budget_remaining_raw").unwrap().to_string(),
""
);
assert_eq!(ctx.get_attr("budget_pct_raw").unwrap().to_string(), "");
}
}