use crate::checkpoint::Checkpoint;
use crate::config::Config;
use crate::github_api::GithubApi;
use crate::logger;
use std::path::PathBuf;
fn checkpoint_path() -> Option<PathBuf> {
std::env::var("HOME").ok().map(|home| {
PathBuf::from(home)
.join(".config")
.join("vibestats")
.join("checkpoint.toml")
})
}
pub fn list() {
let config = Config::load_or_exit();
let api = GithubApi::new(&config.oauth_token, &config.vibestats_data_repo);
match api.get_file_content("registry.json") {
Ok(None) => {
println!("vibestats: no machines registered");
}
Ok(Some(content)) => {
match serde_json::from_str::<serde_json::Value>(&content) {
Ok(json) => {
match json["machines"].as_array() {
None => {
println!("vibestats: registry.json is malformed");
}
Some(machines) => {
if machines.is_empty() {
println!("vibestats: no machines registered");
return;
}
let rows: Vec<(String, String, String, String)> = machines
.iter()
.map(|m| {
(
m["machine_id"].as_str().unwrap_or("").to_string(),
m["hostname"].as_str().unwrap_or("").to_string(),
m["status"].as_str().unwrap_or("").to_string(),
m["last_seen"].as_str().unwrap_or("").to_string(),
)
})
.collect();
let w_id = rows.iter().map(|r| r.0.len()).max().unwrap_or(0);
let w_host = rows.iter().map(|r| r.1.len()).max().unwrap_or(0);
let w_status = rows.iter().map(|r| r.2.len()).max().unwrap_or(0);
for (machine_id, hostname, status, last_seen) in &rows {
println!(
"{:<w_id$} {:<w_host$} {:<w_status$} {}",
machine_id,
hostname,
status,
last_seen,
w_id = w_id,
w_host = w_host,
w_status = w_status,
);
}
}
}
}
Err(_) => {
println!("vibestats: registry.json is malformed");
}
}
}
Err(e) => {
logger::error(&format!("machines: failed to fetch registry.json: {}", e));
println!("vibestats: failed to fetch registry — check vibestats.log");
}
}
}
pub fn remove(machine_id: &str, purge_history: bool) {
let config = Config::load_or_exit();
let api = GithubApi::new(&config.oauth_token, &config.vibestats_data_repo);
let content = match api.get_file_content("registry.json") {
Ok(None) => {
println!("vibestats: no machines registered");
return;
}
Ok(Some(c)) => c,
Err(e) => {
logger::error(&format!("machines: failed to fetch registry.json: {}", e));
println!("vibestats: failed to fetch registry — check vibestats.log");
return;
}
};
let mut json: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(e) => {
logger::error(&format!("machines: registry.json is malformed: {}", e));
println!("vibestats: registry.json is malformed");
return;
}
};
let machines = match json["machines"].as_array() {
Some(m) => m.clone(),
None => {
println!("vibestats: registry.json is malformed");
return;
}
};
let machine_index = machines
.iter()
.position(|m| m["machine_id"].as_str() == Some(machine_id));
let idx = match machine_index {
Some(i) => i,
None => {
println!("vibestats: machine '{}' not found in registry", machine_id);
return;
}
};
let hostname = machines[idx]["hostname"]
.as_str()
.unwrap_or(machine_id)
.to_string();
if !purge_history {
json["machines"][idx]["status"] = serde_json::Value::String("retired".to_string());
let updated_json = match serde_json::to_string_pretty(&json) {
Ok(s) => s,
Err(e) => {
logger::error(&format!("machines: failed to serialize registry: {}", e));
println!("vibestats: failed to update registry — check vibestats.log");
return;
}
};
if let Err(e) = api.put_file("registry.json", &updated_json) {
logger::error(&format!("machines: failed to update registry.json: {}", e));
println!("vibestats: failed to update registry — check vibestats.log");
return;
}
if machine_id == config.machine_id {
update_local_checkpoint("retired");
}
println!("vibestats: machine '{}' retired", machine_id);
} else {
print!(
"This will permanently remove all historical data for {}. Continue? (y/N): ",
hostname
);
use std::io::Write;
if let Err(e) = std::io::stdout().flush() {
logger::error(&format!("machines: failed to flush stdout: {}", e));
}
let mut input = String::new();
let confirmed = match std::io::stdin().read_line(&mut input) {
Ok(_) => {
let trimmed = input.trim();
trimmed == "y" || trimmed == "Y"
}
Err(e) => {
logger::error(&format!("machines: failed to read stdin: {}", e));
false }
};
if !confirmed {
println!("vibestats: purge cancelled");
return;
}
json["machines"][idx]["status"] = serde_json::Value::String("purged".to_string());
let updated_json = match serde_json::to_string_pretty(&json) {
Ok(s) => s,
Err(e) => {
logger::error(&format!("machines: failed to serialize registry: {}", e));
println!("vibestats: failed to update registry — check vibestats.log");
return;
}
};
if let Err(e) = api.put_file("registry.json", &updated_json) {
logger::error(&format!("machines: failed to update registry.json: {}", e));
println!("vibestats: failed to update registry — check vibestats.log");
return;
}
let deleted_count = if machine_id == config.machine_id {
purge_self(&api, machine_id)
} else {
purge_remote(&api, machine_id)
};
if machine_id == config.machine_id {
update_local_checkpoint("purged");
}
println!(
"vibestats: machine '{}' purged — {} file(s) deleted",
machine_id, deleted_count
);
}
}
fn update_local_checkpoint(status: &str) {
if let Some(ref path) = checkpoint_path() {
let mut cp = Checkpoint::load(path);
cp.set_machine_status(status);
if let Err(e) = cp.save(path) {
logger::error(&format!("machines: failed to save checkpoint: {}", e));
}
}
}
fn purge_self(api: &GithubApi, machine_id: &str) -> usize {
let mut deleted = 0usize;
let cp_path = checkpoint_path();
let checkpoint = cp_path.as_deref().map(Checkpoint::load).unwrap_or_default();
for key in checkpoint.date_hashes.keys() {
let (harness, date) = key.split_once(':').unwrap_or(("claude", key.as_str()));
let parts: Vec<&str> = date.split('-').collect();
if parts.len() == 3 {
let hive_path = format!(
"machines/year={}/month={}/day={}/harness={}/machine_id={}/data.json",
parts[0], parts[1], parts[2], harness, machine_id
);
match api.delete_file(&hive_path) {
Ok(()) => {
deleted += 1;
}
Err(e) => {
logger::error(&format!("machines: failed to delete {}: {}", hive_path, e));
}
}
}
}
deleted
}
fn purge_remote(api: &GithubApi, machine_id: &str) -> usize {
let mut deleted = 0usize;
let base_path = "machines";
let (_, year_dirs) = match api.list_directory_all(base_path) {
Ok(entries) => entries,
Err(e) => {
logger::error(&format!(
"machines: failed to list directory {}: {}",
base_path, e
));
return deleted;
}
};
for year_dir in &year_dirs {
let (_, month_dirs) = match api.list_directory_all(year_dir) {
Ok(entries) => entries,
Err(e) => {
logger::error(&format!(
"machines: failed to list directory {}: {}",
year_dir, e
));
continue;
}
};
for month_dir in &month_dirs {
let (_, day_dirs) = match api.list_directory_all(month_dir) {
Ok(entries) => entries,
Err(e) => {
logger::error(&format!(
"machines: failed to list directory {}: {}",
month_dir, e
));
continue;
}
};
for day_dir in &day_dirs {
let (_, harness_dirs) = match api.list_directory_all(day_dir) {
Ok(entries) => entries,
Err(e) => {
logger::error(&format!(
"machines: failed to list directory {}: {}",
day_dir, e
));
continue;
}
};
for harness_dir in &harness_dirs {
let (_, machine_dirs) = match api.list_directory_all(harness_dir) {
Ok(entries) => entries,
Err(e) => {
logger::error(&format!(
"machines: failed to list directory {}: {}",
harness_dir, e
));
continue;
}
};
for machine_dir in &machine_dirs {
let target_segment = format!("machine_id={}", machine_id);
let is_match = machine_dir
.split('/')
.next_back()
.map(|seg| seg == target_segment)
.unwrap_or(false);
if !is_match {
continue;
}
let (data_files, _) = match api.list_directory_all(machine_dir) {
Ok(entries) => entries,
Err(e) => {
logger::error(&format!(
"machines: failed to list directory {}: {}",
machine_dir, e
));
continue;
}
};
for file_path in &data_files {
match api.delete_file(file_path) {
Ok(()) => {
deleted += 1;
}
Err(e) => {
logger::error(&format!(
"machines: failed to delete {}: {}",
file_path, e
));
}
}
}
}
}
}
}
}
deleted
}
#[cfg(test)]
mod tests {
#[test]
fn test_registry_parse_extracts_machine_fields() {
let registry_json = r#"{
"machines": [
{
"machine_id": "stephens-mbp-a1b2c3",
"hostname": "Stephens-MacBook-Pro.local",
"status": "active",
"last_seen": "2026-04-10T14:23:00Z"
}
]
}"#;
let json: serde_json::Value = serde_json::from_str(registry_json).unwrap();
let machines = json["machines"].as_array().unwrap();
assert_eq!(machines.len(), 1);
let m = &machines[0];
assert_eq!(m["machine_id"].as_str().unwrap(), "stephens-mbp-a1b2c3");
assert_eq!(
m["hostname"].as_str().unwrap(),
"Stephens-MacBook-Pro.local"
);
assert_eq!(m["status"].as_str().unwrap(), "active");
assert_eq!(m["last_seen"].as_str().unwrap(), "2026-04-10T14:23:00Z");
}
#[test]
fn test_registry_parse_multiple_machines() {
let registry_json = r#"{
"machines": [
{
"machine_id": "mbp-a1b2c3",
"hostname": "MacBook-Pro.local",
"status": "active",
"last_seen": "2026-04-10T14:23:00Z"
},
{
"machine_id": "ubuntu-d4e5f6",
"hostname": "work-ubuntu",
"status": "retired",
"last_seen": "2026-03-15T09:10:00Z"
}
]
}"#;
let json: serde_json::Value = serde_json::from_str(registry_json).unwrap();
let machines = json["machines"].as_array().unwrap();
assert_eq!(machines.len(), 2);
assert_eq!(machines[1]["status"].as_str().unwrap(), "retired");
}
#[test]
fn test_retire_mutation_sets_status_to_retired() {
let registry_json = r#"{
"machines": [
{
"machine_id": "mbp-a1b2c3",
"hostname": "MacBook-Pro.local",
"status": "active",
"last_seen": "2026-04-10T14:23:00Z"
}
]
}"#;
let mut json: serde_json::Value = serde_json::from_str(registry_json).unwrap();
json["machines"][0]["status"] = serde_json::Value::String("retired".to_string());
let updated: serde_json::Value =
serde_json::from_str(&serde_json::to_string_pretty(&json).unwrap()).unwrap();
assert_eq!(
updated["machines"][0]["status"].as_str().unwrap(),
"retired"
);
assert_eq!(
updated["machines"][0]["machine_id"].as_str().unwrap(),
"mbp-a1b2c3"
);
assert_eq!(
updated["machines"][0]["hostname"].as_str().unwrap(),
"MacBook-Pro.local"
);
}
#[test]
fn test_machine_not_found_returns_none() {
let registry_json = r#"{
"machines": [
{
"machine_id": "mbp-a1b2c3",
"hostname": "MacBook-Pro.local",
"status": "active",
"last_seen": "2026-04-10T14:23:00Z"
}
]
}"#;
let json: serde_json::Value = serde_json::from_str(registry_json).unwrap();
let machines = json["machines"].as_array().unwrap();
let found = machines
.iter()
.position(|m| m["machine_id"].as_str() == Some("nonexistent-machine-id"));
assert!(
found.is_none(),
"machine_id not in registry must return None"
);
}
#[test]
fn test_confirm_accepts_lowercase_y() {
assert!(is_confirmed("y"), "'y' must be accepted as confirmation");
}
#[test]
fn test_confirm_accepts_uppercase_y() {
assert!(is_confirmed("Y"), "'Y' must be accepted as confirmation");
}
#[test]
fn test_confirm_rejects_n() {
assert!(!is_confirmed("n"), "'n' must not be accepted");
}
#[test]
fn test_confirm_rejects_empty() {
assert!(
!is_confirmed(""),
"empty input must not be accepted (default is N)"
);
}
#[test]
fn test_confirm_rejects_yes() {
assert!(
!is_confirmed("yes"),
"'yes' must not be accepted (only 'y' or 'Y')"
);
}
#[test]
fn test_confirm_rejects_whitespace() {
assert!(!is_confirmed(" "), "whitespace must not be accepted");
}
fn is_confirmed(input: &str) -> bool {
let trimmed = input.trim();
trimmed == "y" || trimmed == "Y"
}
#[test]
fn test_hive_path_zero_padded() {
let date = "2026-04-09";
let machine_id = "mbp-a1b2c3";
let parts: Vec<&str> = date.split('-').collect();
assert_eq!(parts.len(), 3);
let hive_path = format!(
"machines/year={}/month={}/day={}/harness=claude/machine_id={}/data.json",
parts[0], parts[1], parts[2], machine_id
);
assert_eq!(
hive_path,
"machines/year=2026/month=04/day=09/harness=claude/machine_id=mbp-a1b2c3/data.json"
);
}
#[test]
fn test_hive_path_double_digit_day() {
let date = "2026-12-31";
let machine_id = "test-machine";
let parts: Vec<&str> = date.split('-').collect();
let hive_path = format!(
"machines/year={}/month={}/day={}/harness=claude/machine_id={}/data.json",
parts[0], parts[1], parts[2], machine_id
);
assert_eq!(
hive_path,
"machines/year=2026/month=12/day=31/harness=claude/machine_id=test-machine/data.json"
);
}
}