Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
223 changes: 223 additions & 0 deletions crates/tokscale-cli/src/device.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
use anyhow::{anyhow, Context, Result};
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use uuid::Uuid;

const DEVICE_FILE_NAME: &str = "device.json";
const DEVICE_ID_ENV: &str = "TOKSCALE_DEVICE_ID";
const DEVICE_NAME_ENV: &str = "TOKSCALE_DEVICE_NAME";
const MAX_DEVICE_ID_LEN: usize = 96;
const MAX_DEVICE_NAME_LEN: usize = 120;

#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SubmitDevice {
pub id: String,
pub name: Option<String>,
}

#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
struct StoredDevice {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
created_at: String,
}

pub fn resolve_submit_device() -> Result<SubmitDevice> {
if let Some(id) = env_value(DEVICE_ID_ENV) {
return Ok(SubmitDevice {
id: validate_device_id(&id)?,
name: env_value(DEVICE_NAME_ENV)
.map(|name| validate_device_name(&name))
.transpose()?,
});
}

let path = device_file_path();
let name_override = env_value(DEVICE_NAME_ENV)
.map(|name| validate_device_name(&name))
.transpose()?;

if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("failed to read {}", path.display()))?;
let stored: StoredDevice = serde_json::from_str(&content)
.with_context(|| format!("failed to parse {}", path.display()))?;
return Ok(SubmitDevice {
id: validate_device_id(&stored.id)?,
name: name_override.or(stored.name),
});
}

let stored = StoredDevice {
id: format!("dev_{}", Uuid::new_v4().simple()),
name: name_override,
created_at: Utc::now().to_rfc3339(),
};
write_stored_device(&path, &stored)?;

Ok(SubmitDevice {
id: stored.id,
name: stored.name,
})
}

fn env_value(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}

fn device_file_path() -> PathBuf {
crate::paths::get_config_dir().join(DEVICE_FILE_NAME)
}

fn validate_device_id(id: &str) -> Result<String> {
let trimmed = id.trim();
if trimmed.is_empty() {
return Err(anyhow!("{} must not be empty", DEVICE_ID_ENV));
}
if trimmed.len() > MAX_DEVICE_ID_LEN {
return Err(anyhow!(
"{} must be at most {} characters",
DEVICE_ID_ENV,
MAX_DEVICE_ID_LEN
));
}
if !trimmed
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':'))
{
return Err(anyhow!(
"{} may only contain ASCII letters, numbers, '.', '_', '-', or ':'",
DEVICE_ID_ENV
));
}
Ok(trimmed.to_string())
}

fn validate_device_name(name: &str) -> Result<String> {
let trimmed = name.trim();
if trimmed.is_empty() {
return Err(anyhow!("{} must not be empty", DEVICE_NAME_ENV));
}
if trimmed.len() > MAX_DEVICE_NAME_LEN {
return Err(anyhow!(
"{} must be at most {} characters",
DEVICE_NAME_ENV,
MAX_DEVICE_NAME_LEN
));
}
Ok(trimmed.to_string())
}

fn write_stored_device(path: &Path, device: &StoredDevice) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("failed to create {}", parent.display()))?;
}

let tmp_path = path.with_extension("json.tmp");
let content = serde_json::to_string_pretty(device)?;
std::fs::write(&tmp_path, content)
.with_context(|| format!("failed to write {}", tmp_path.display()))?;
tokscale_core::fs_atomic::replace_file(&tmp_path, path)
.with_context(|| format!("failed to replace {}", path.display()))?;
Ok(())
}

#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;

fn save_env() -> (
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
) {
(
env::var_os("TOKSCALE_CONFIG_DIR"),
env::var_os("TOKSCALE_DEVICE_ID"),
env::var_os("TOKSCALE_DEVICE_NAME"),
)
}

struct EnvRestore(
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
);

impl Drop for EnvRestore {
fn drop(&mut self) {
restore_env((self.0.clone(), self.1.clone(), self.2.clone()));
}
}

fn restore_env(
prev: (
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
Option<std::ffi::OsString>,
),
) {
unsafe {
match prev.0 {
Some(v) => env::set_var("TOKSCALE_CONFIG_DIR", v),
None => env::remove_var("TOKSCALE_CONFIG_DIR"),
}
match prev.1 {
Some(v) => env::set_var("TOKSCALE_DEVICE_ID", v),
None => env::remove_var("TOKSCALE_DEVICE_ID"),
}
match prev.2 {
Some(v) => env::set_var("TOKSCALE_DEVICE_NAME", v),
None => env::remove_var("TOKSCALE_DEVICE_NAME"),
}
}
}

#[test]
#[serial]
fn env_device_id_is_used_without_touching_config_file() {
let prev = save_env();
let _restore = EnvRestore(prev.0, prev.1, prev.2);
let dir = tempfile::tempdir().unwrap();
unsafe {
env::set_var("TOKSCALE_CONFIG_DIR", dir.path());
env::set_var("TOKSCALE_DEVICE_ID", "dev_ci");
env::set_var("TOKSCALE_DEVICE_NAME", "CI runner");
}

let device = resolve_submit_device().unwrap();

assert_eq!(device.id, "dev_ci");
assert_eq!(device.name.as_deref(), Some("CI runner"));
assert!(!dir.path().join("device.json").exists());
}

#[test]
#[serial]
fn generated_device_id_is_stable_in_config_dir() {
let prev = save_env();
let _restore = EnvRestore(prev.0, prev.1, prev.2);
let dir = tempfile::tempdir().unwrap();
unsafe {
env::set_var("TOKSCALE_CONFIG_DIR", dir.path());
env::remove_var("TOKSCALE_DEVICE_ID");
env::remove_var("TOKSCALE_DEVICE_NAME");
}

let first = resolve_submit_device().unwrap();
let second = resolve_submit_device().unwrap();

assert!(first.id.starts_with("dev_"));
assert_eq!(first, second);
assert!(dir.path().join("device.json").exists());
}
}
48 changes: 45 additions & 3 deletions crates/tokscale-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ mod antigravity;
mod auth;
mod commands;
mod cursor;
mod device;
mod paths;
mod trae;
mod tui;
Expand Down Expand Up @@ -3619,16 +3620,29 @@ struct TsExportMeta {
date_range: DateRange,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TsSubmitDevice {
id: String,
#[serde(skip_serializing_if = "Option::is_none")]
name: Option<String>,
}

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct TsTokenContributionData {
meta: TsExportMeta,
#[serde(skip_serializing_if = "Option::is_none")]
device: Option<TsSubmitDevice>,
summary: TsDataSummary,
years: Vec<TsYearSummary>,
contributions: Vec<TsDailyContribution>,
}

fn to_ts_token_contribution_data(graph: &tokscale_core::GraphResult) -> TsTokenContributionData {
fn to_ts_token_contribution_data(
graph: &tokscale_core::GraphResult,
device: Option<&device::SubmitDevice>,
) -> TsTokenContributionData {
TsTokenContributionData {
meta: TsExportMeta {
generated_at: graph.meta.generated_at.clone(),
Expand All @@ -3638,6 +3652,10 @@ fn to_ts_token_contribution_data(graph: &tokscale_core::GraphResult) -> TsTokenC
end: graph.meta.date_range_end.clone(),
},
},
device: device.map(|d| TsSubmitDevice {
id: d.id.clone(),
name: d.name.clone(),
}),
summary: TsDataSummary {
total_tokens: graph.summary.total_tokens,
total_cost: graph.summary.total_cost,
Expand Down Expand Up @@ -4082,7 +4100,7 @@ fn run_graph_command(
.map_err(|e| anyhow::anyhow!(e))?;

let processing_time_ms = start.elapsed().as_millis() as u32;
let output_data = to_ts_token_contribution_data(&graph_result);
let output_data = to_ts_token_contribution_data(&graph_result, None);
let json_output = serde_json::to_string_pretty(&output_data)?;

if let Some(output_path) = output {
Expand Down Expand Up @@ -4335,7 +4353,8 @@ fn run_submit_command(

let api_url = auth::get_api_base_url();

let submit_payload = to_ts_token_contribution_data(&graph_result);
let submit_device = device::resolve_submit_device()?;
let submit_payload = to_ts_token_contribution_data(&graph_result, Some(&submit_device));

let response = rt.block_on(async {
reqwest::Client::new()
Expand Down Expand Up @@ -6141,6 +6160,29 @@ mod tests {
assert_eq!(graph.years.len(), original_years.len());
}

#[test]
fn test_submit_payload_includes_device_when_provided() {
let graph = graph_result_with_contributions(vec![daily_contribution(
"2026-12-31",
20,
2.50,
"codex",
"model-b",
)]);
let device = device::SubmitDevice {
id: "dev_test".to_string(),
name: Some("Test device".to_string()),
};

let payload = to_ts_token_contribution_data(&graph, Some(&device));

assert_eq!(payload.device.as_ref().unwrap().id, "dev_test");
assert_eq!(
payload.device.as_ref().unwrap().name.as_deref(),
Some("Test device")
);
}

#[test]
#[cfg(target_os = "macos")]
#[serial_test::serial]
Expand Down
Loading
Loading