context-bar-core 0.6.1

Engine for context-bar: AI coding-agent usage, rolling quota windows, and API-equivalent cost estimation from local transcripts.
Documentation
use serde::{Deserialize, Serialize};
use std::io::Read;
use std::path::PathBuf;

const DEFAULT_RELATIVE_PATH: &str = ".context-bar/claude-statusline.json";

#[derive(Debug, Deserialize)]
struct StatuslineInput {
    #[serde(default)]
    model: ModelInfo,
    #[serde(default)]
    cwd: Option<String>,
    #[serde(default)]
    workspace: WorkspaceInfo,
    #[serde(default)]
    context_window: ContextWindowInfo,
    #[serde(default)]
    rate_limits: serde_json::Value,
}

#[derive(Debug, Default, Deserialize)]
struct ModelInfo {
    #[serde(default)]
    id: Option<String>,
    #[serde(default)]
    display_name: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct WorkspaceInfo {
    #[serde(default)]
    current_dir: Option<String>,
}

#[derive(Debug, Default, Deserialize)]
struct ContextWindowInfo {
    #[serde(default)]
    used_percentage: Option<f64>,
    #[serde(default)]
    context_window_size: Option<u64>,
    #[serde(default)]
    total_input_tokens: Option<u64>,
    #[serde(default)]
    total_output_tokens: Option<u64>,
    #[serde(default)]
    current_usage: CurrentUsage,
}

#[derive(Debug, Default, Deserialize, Serialize)]
struct CurrentUsage {
    #[serde(default)]
    input_tokens: Option<u64>,
    #[serde(default)]
    output_tokens: Option<u64>,
    #[serde(default)]
    cache_creation_input_tokens: Option<u64>,
    #[serde(default)]
    cache_read_input_tokens: Option<u64>,
}

#[derive(Debug, Serialize)]
struct StatuslineSnapshot {
    updated_at: String,
    model: SnapshotModel,
    cwd: Option<String>,
    workspace: SnapshotWorkspace,
    context_window: SnapshotContextWindow,
    #[serde(skip_serializing_if = "serde_json::Value::is_null")]
    rate_limits: serde_json::Value,
}

#[derive(Debug, Serialize)]
struct SnapshotModel {
    id: Option<String>,
    display_name: Option<String>,
}

#[derive(Debug, Serialize)]
struct SnapshotWorkspace {
    current_dir: Option<String>,
}

#[derive(Debug, Serialize)]
struct SnapshotContextWindow {
    #[serde(skip_serializing_if = "Option::is_none")]
    used_percentage: Option<f64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    context_window_size: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    total_input_tokens: Option<u64>,
    #[serde(skip_serializing_if = "Option::is_none")]
    total_output_tokens: Option<u64>,
    current_usage: CurrentUsage,
}

pub fn default_snapshot_path() -> PathBuf {
    if let Ok(override_path) = std::env::var("CONTEXTBAR_CLAUDE_STATUSLINE_PATH") {
        return PathBuf::from(override_path);
    }
    let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
    PathBuf::from(home).join(DEFAULT_RELATIVE_PATH)
}

pub fn write_snapshot_from_stdin(path: Option<PathBuf>) -> Result<String, String> {
    let mut input = String::new();
    std::io::stdin()
        .read_to_string(&mut input)
        .map_err(|error| format!("read stdin failed: {error}"))?;

    let parsed: StatuslineInput = serde_json::from_str(&input)
        .map_err(|error| format!("parse statusline JSON failed: {error}"))?;

    let now = time::OffsetDateTime::now_utc()
        .format(&time::format_description::well_known::Rfc3339)
        .unwrap_or_else(|_| "unknown".to_string());

    let line = render_statusline(&parsed);
    let cwd = parsed.workspace.current_dir.clone().or(parsed.cwd.clone());
    let snapshot = StatuslineSnapshot {
        updated_at: now,
        model: SnapshotModel {
            id: parsed.model.id.clone(),
            display_name: parsed.model.display_name.clone(),
        },
        cwd,
        workspace: SnapshotWorkspace {
            current_dir: parsed.workspace.current_dir.clone(),
        },
        context_window: SnapshotContextWindow {
            used_percentage: parsed.context_window.used_percentage,
            context_window_size: parsed.context_window.context_window_size,
            total_input_tokens: parsed.context_window.total_input_tokens,
            total_output_tokens: parsed.context_window.total_output_tokens,
            current_usage: parsed.context_window.current_usage,
        },
        rate_limits: parsed.rate_limits,
    };

    let output_path = path.unwrap_or_else(default_snapshot_path);
    if let Some(parent) = output_path.parent() {
        std::fs::create_dir_all(parent)
            .map_err(|error| format!("mkdir {} failed: {error}", parent.display()))?;
    }
    let body = serde_json::to_vec_pretty(&snapshot)
        .map_err(|error| format!("serialize statusline snapshot failed: {error}"))?;
    crate::state_writer::atomic_write(&output_path, &body)?;

    Ok(line)
}

fn render_statusline(input: &StatuslineInput) -> String {
    let model = input
        .model
        .display_name
        .as_deref()
        .or(input.model.id.as_deref())
        .unwrap_or("Claude");
    let pct = input.context_window.used_percentage.unwrap_or(0.0);
    format!("[{model}] {pct:.0}% ctx")
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn renders_compact_statusline() {
        let parsed = StatuslineInput {
            model: ModelInfo {
                id: Some("claude-sonnet-4-6".to_string()),
                display_name: Some("Sonnet 4.6".to_string()),
            },
            cwd: None,
            workspace: WorkspaceInfo {
                current_dir: Some("/tmp/demo".to_string()),
            },
            context_window: ContextWindowInfo {
                used_percentage: Some(6.2),
                ..Default::default()
            },
            rate_limits: serde_json::Value::Null,
        };
        assert_eq!(render_statusline(&parsed), "[Sonnet 4.6] 6% ctx");
    }

    #[test]
    fn default_path_uses_home() {
        let path = default_snapshot_path();
        assert!(path.ends_with(".context-bar/claude-statusline.json"));
    }
}