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}")
}
}
#[derive(Deserialize)]
pub struct Config {
pub format: Option<FormatConfig>,
}
#[derive(Deserialize)]
pub struct FormatConfig {
pub template: Option<String>,
}
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 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_template() -> (String, Option<String>) {
load_template_from_path(&config_path())
}
pub fn load_template_from_path(path: &str) -> (String, Option<String>) {
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => return (DEFAULT_TEMPLATE.to_string(), None),
};
let config: Config = match toml::from_str(&content) {
Ok(c) => c,
Err(e) => {
return (
DEFAULT_TEMPLATE.to_string(),
Some(format!("TOML parse error: {e}")),
);
}
};
match config.format.and_then(|f| f.template) {
Some(t) => (t, None),
None => (DEFAULT_TEMPLATE.to_string(), None),
}
}
pub fn build_context(
input: &crate::Input,
monthly_total: Option<f64>,
branch: &str,
git_dirty: bool,
) -> 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 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 { "" };
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,
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),
}
}
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),
}),
};
build_context(&input, Some(42.00), "feature/templates", true)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn load_template_returns_default_when_no_file() {
let (template, err) =
load_template_from_path("/tmp/paddington-test-nonexistent/config.toml");
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 (template, err) = load_template_from_path(path.to_str().unwrap());
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 (template, err) = load_template_from_path(path.to_str().unwrap());
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);
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);
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);
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);
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("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),
}),
};
let ctx = build_context(&input, Some(42.00), "main", false);
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/mo"));
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);
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);
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);
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");
}
}