use crate::report::human_bytes;
use anyhow::{bail, Context, Result};
use cavs_workspace::{sharing, Branch, BranchVisibility, Depot, DepotIndex, Platform, Workspace};
use serde::Serialize;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
fn now() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn open(workspace: &Path) -> Result<Workspace> {
Workspace::open(workspace)
}
pub fn init(workspace: &Path, app: &str) -> Result<()> {
Workspace::init(workspace, app)?;
println!("workspace: {} (app '{app}')", workspace.display());
println!("next : cavs depot add <id> · cavs branch add <id> · cavs build create");
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub fn depot_add(
workspace: &Path,
app: Option<&str>,
id: &str,
name: Option<&str>,
platform: Option<&str>,
language: Option<&str>,
optional: bool,
) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
let platform = match platform {
Some(p) => Some(
Platform::parse(p)
.ok_or_else(|| anyhow::anyhow!("unknown platform '{p}' (windows|linux|macos)"))?,
),
None => None,
};
ws.add_depot(
&app_id,
Depot {
id: id.into(),
name: name.unwrap_or(id).into(),
platform,
language: language.map(String::from),
optional,
},
)?;
println!("depot : '{id}' added to app '{app_id}'");
Ok(())
}
pub fn branch_add(
workspace: &Path,
app: Option<&str>,
id: &str,
name: Option<&str>,
private: bool,
) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
ws.add_branch(
&app_id,
Branch {
id: id.into(),
name: name.unwrap_or(id).into(),
visibility: if private {
BranchVisibility::Private
} else {
BranchVisibility::Public
},
current_build: None,
history: Vec::new(),
},
)?;
println!("branch : '{id}' added to app '{app_id}'");
Ok(())
}
pub fn parse_depot_specs(specs: &[String]) -> Result<Vec<(String, PathBuf)>> {
let mut out = Vec::new();
for spec in specs {
let (id, path) = spec
.split_once('=')
.with_context(|| format!("--depot expects id=path, got '{spec}'"))?;
out.push((id.trim().to_string(), PathBuf::from(path.trim())));
}
Ok(out)
}
pub fn build_create(
workspace: &Path,
app: Option<&str>,
branch: Option<&str>,
label: Option<&str>,
depot_specs: &[String],
) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
let depots = parse_depot_specs(depot_specs)?;
let build = ws.create_build(&app_id, label, branch, &depots, now())?;
println!(
"build : {} created ({} depots{})",
build.id,
build.depots.len(),
label.map(|l| format!(", label '{l}'")).unwrap_or_default()
);
for d in &build.depots {
println!(
" {:<16} {:>10} {} files ({})",
d.depot_id,
human_bytes(d.total_bytes),
d.files,
d.source_path
);
}
if let Some(b) = branch {
println!("branch : '{b}' now points at {}", build.id);
}
Ok(())
}
pub fn branch_promote(
workspace: &Path,
app: Option<&str>,
branch: &str,
build: &str,
) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
ws.promote(&app_id, branch, build)?;
println!("promote : branch '{branch}' → {build}");
Ok(())
}
pub fn branch_rollback(workspace: &Path, app: Option<&str>, branch: &str, to: &str) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
ws.rollback(&app_id, branch, to)?;
println!("rollback : branch '{branch}' → {to}");
Ok(())
}
#[derive(Serialize)]
struct DepotDelta {
depot: String,
status: String, old_bytes: u64,
new_bytes: u64,
estimated_update_bytes: u64,
}
pub fn branch_promote_preview(
workspace: &Path,
app: Option<&str>,
branch: &str,
build: &str,
json: bool,
) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
let app_meta = ws.load_app(&app_id)?;
let candidate = ws.build(&app_id, build)?;
let current_id = app_meta.branch(branch)?.current_build.clone();
let Some(current_id) = current_id else {
println!(
"promote-preview: branch '{branch}' serves nothing yet — promotion is a \
fresh install of {} for every client",
build
);
return Ok(());
};
let current = ws.build(&app_id, ¤t_id)?;
let mut rows: Vec<DepotDelta> = Vec::new();
for db in &candidate.depots {
let new_idx = ws.depot_index(&app_id, &candidate.id, &db.depot_id)?;
match current.depots.iter().find(|d| d.depot_id == db.depot_id) {
Some(_) => {
let old_idx = ws.depot_index(&app_id, ¤t.id, &db.depot_id)?;
let update = update_bytes(&old_idx, &new_idx);
rows.push(DepotDelta {
depot: db.depot_id.clone(),
status: if update == 0 { "unchanged" } else { "updated" }.into(),
old_bytes: old_idx.total_bytes,
new_bytes: new_idx.total_bytes,
estimated_update_bytes: update,
});
}
None => rows.push(DepotDelta {
depot: db.depot_id.clone(),
status: "new".into(),
old_bytes: 0,
new_bytes: new_idx.total_bytes,
estimated_update_bytes: new_idx.total_bytes,
}),
}
}
for db in ¤t.depots {
if !candidate.depots.iter().any(|d| d.depot_id == db.depot_id) {
rows.push(DepotDelta {
depot: db.depot_id.clone(),
status: "removed".into(),
old_bytes: db.total_bytes,
new_bytes: 0,
estimated_update_bytes: 0,
});
}
}
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
return Ok(());
}
println!(
"promote-preview: branch '{branch}' {} → {}",
current.id, candidate.id
);
let mut total = 0u64;
for r in &rows {
total += r.estimated_update_bytes;
println!(
" {:<16} {:<10} {:>10} → {:>10} update ~{}",
r.depot,
r.status,
human_bytes(r.old_bytes),
human_bytes(r.new_bytes),
human_bytes(r.estimated_update_bytes)
);
}
println!(
"total : ~{} estimated update per client",
human_bytes(total)
);
println!("note : estimates from content indices (raw new chunk bytes)");
Ok(())
}
fn update_bytes(old: &DepotIndex, new: &DepotIndex) -> u64 {
let mut old_chunks: HashMap<&str, ()> = HashMap::new();
for chunks in old.files.values() {
for (hash, _) in chunks {
old_chunks.insert(hash.as_str(), ());
}
}
let mut update = 0u64;
let mut seen: HashMap<&str, ()> = HashMap::new();
for chunks in new.files.values() {
for (hash, len) in chunks {
if !old_chunks.contains_key(hash.as_str()) && seen.insert(hash.as_str(), ()).is_none() {
update += len;
}
}
}
update
}
pub fn depot_analyze_sharing(
workspace: &Path,
app: Option<&str>,
build: Option<&str>,
out: Option<&Path>,
json: bool,
) -> Result<()> {
let ws = open(workspace)?;
let app_id = ws.app_id(app)?;
let build = match build {
Some(id) => ws.build(&app_id, id)?,
None => ws
.builds(&app_id)?
.into_iter()
.last()
.with_context(|| format!("app '{app_id}' has no builds yet"))?,
};
if build.depots.len() < 2 {
bail!(
"build '{}' has {} depot(s); sharing needs at least 2",
build.id,
build.depots.len()
);
}
let mut indices = Vec::new();
for d in &build.depots {
indices.push(ws.depot_index(&app_id, &build.id, &d.depot_id)?);
}
let pairs = sharing::matrix(&indices);
if json {
println!("{}", serde_json::to_string_pretty(&pairs)?);
return Ok(());
}
println!("depot sharing (build {}):", build.id);
for p in &pairs {
println!(
" {:<14} ↔ {:<14} shared {:>10} unique {:>10} / {:>10} reuse {:>5.1}%",
p.depot_a,
p.depot_b,
human_bytes(p.shared_bytes),
human_bytes(p.unique_a_bytes),
human_bytes(p.unique_b_bytes),
p.reuse_ratio * 100.0
);
}
for p in &pairs {
if p.reuse_ratio > 0.3 && p.shared_bytes > 1 << 20 {
println!(
"suggest : '{}' and '{}' share {} — consider a shared depot so the \
content ships and stores once",
p.depot_a,
p.depot_b,
human_bytes(p.shared_bytes)
);
}
}
if let Some(path) = out {
let mut md = String::from("# Depot Sharing\n\n| Depot A | Depot B | Shared bytes | Unique A | Unique B | Reuse % |\n|---|---|---:|---:|---:|---:|\n");
for p in &pairs {
md.push_str(&format!(
"| {} | {} | {} | {} | {} | {:.1}% |\n",
p.depot_a,
p.depot_b,
human_bytes(p.shared_bytes),
human_bytes(p.unique_a_bytes),
human_bytes(p.unique_b_bytes),
p.reuse_ratio * 100.0
));
}
std::fs::write(path, md)?;
eprintln!("report : {}", path.display());
}
Ok(())
}
pub struct InstallPlanArgs<'a> {
pub workspace: &'a Path,
pub app: Option<&'a str>,
pub branch: Option<&'a str>,
pub platform: Option<&'a str>,
pub language: Option<&'a str>,
pub owned: Vec<String>,
pub from_build: Option<&'a str>,
pub to_build: Option<&'a str>,
pub json: bool,
pub out: Option<&'a Path>,
}
#[derive(Serialize)]
struct DepotPlan {
depot: String,
action: String, bytes: u64,
route: String,
}
#[derive(Serialize)]
struct InstallPlan {
app: String,
branch: Option<String>,
platform: Option<String>,
language: Option<String>,
owned: Vec<String>,
from_build: Option<String>,
to_build: String,
depots: Vec<DepotPlan>,
total_bytes: u64,
note: String,
}
pub fn install_plan(args: &InstallPlanArgs) -> Result<()> {
let ws = open(args.workspace)?;
let app_id = ws.app_id(args.app)?;
let app = ws.load_app(&app_id)?;
let to_id = match (args.to_build, args.branch) {
(Some(id), _) => id.to_string(),
(None, Some(branch)) => app
.branch(branch)?
.current_build
.clone()
.with_context(|| format!("branch '{branch}' serves no build yet"))?,
(None, None) => bail!("CAVS-E-INSTALL-PLAN-INVALID: pass --to or --branch"),
};
let to = ws.build(&app_id, &to_id)?;
let from = match args.from_build {
Some(id) => Some(ws.build(&app_id, id)?),
None => None,
};
let platform = match args.platform {
Some(p) => Some(
Platform::parse(p)
.ok_or_else(|| anyhow::anyhow!("unknown platform '{p}' (windows|linux|macos)"))?,
),
None => None,
};
let wanted: Vec<&cavs_workspace::DepotBuild> = to
.depots
.iter()
.filter(|db| {
let Ok(meta) = app.depot(&db.depot_id) else {
return false;
};
let platform_ok = match (meta.platform, platform) {
(None, _) => true,
(Some(a), Some(b)) => a == b,
(Some(_), None) => false,
};
let language_ok = match (&meta.language, args.language) {
(None, _) => true,
(Some(a), Some(b)) => a == b,
(Some(_), None) => false,
};
let owned_ok = if args.owned.is_empty() {
!meta.optional
} else {
args.owned.iter().any(|o| o == &db.depot_id) || !meta.optional
};
platform_ok && language_ok && owned_ok
})
.collect();
if wanted.is_empty() {
bail!(
"CAVS-E-INSTALL-PLAN-INVALID: no depots match platform/language/ownership \
in build '{}'",
to.id
);
}
let mut held: Vec<DepotIndex> = Vec::new();
if let Some(from) = &from {
for db in &from.depots {
held.push(ws.depot_index(&app_id, &from.id, &db.depot_id)?);
}
}
let mut plans: Vec<DepotPlan> = Vec::new();
let mut total = 0u64;
for db in &wanted {
let new_idx = ws.depot_index(&app_id, &to.id, &db.depot_id)?;
let had = from
.as_ref()
.map(|f| f.depots.iter().any(|d| d.depot_id == db.depot_id))
.unwrap_or(false);
let held_refs: Vec<&DepotIndex> = held.iter().collect();
let bytes = sharing::fetch_bytes(&new_idx, &held_refs);
held.push(new_idx.clone());
let (action, route) = if bytes == 0 {
("no-op", "none — already up to date")
} else if had {
(
"update",
if bytes * 10 < new_idx.total_bytes {
"CAVS .cavsplan"
} else {
"CAVS chunks / hybrid"
},
)
} else {
(
"install",
if bytes * 2 < new_idx.total_bytes {
"CAVS chunks (cross-depot reuse)"
} else {
"bootstrap"
},
)
};
total += bytes;
plans.push(DepotPlan {
depot: db.depot_id.clone(),
action: action.into(),
bytes,
route: route.into(),
});
}
let plan = InstallPlan {
app: app_id.clone(),
branch: args.branch.map(String::from),
platform: args.platform.map(String::from),
language: args.language.map(String::from),
owned: args.owned.clone(),
from_build: from.as_ref().map(|f| f.id.clone()),
to_build: to.id.clone(),
depots: plans,
total_bytes: total,
note: "estimates from content indices (raw new chunk bytes, before wire \
compression)"
.into(),
};
if args.json {
println!("{}", serde_json::to_string_pretty(&plan)?);
} else {
print_install_plan(&plan);
}
if let Some(path) = args.out {
std::fs::write(path, install_markdown(&plan))?;
eprintln!("report : {}", path.display());
}
Ok(())
}
fn print_install_plan(p: &InstallPlan) {
println!("install-plan: app '{}' → {}", p.app, p.to_build);
println!(
"player : platform {}, language {}, owned [{}], installed {}",
p.platform.as_deref().unwrap_or("any"),
p.language.as_deref().unwrap_or("any"),
p.owned.join(", "),
p.from_build.as_deref().unwrap_or("nothing")
);
for d in &p.depots {
println!(
" {:<16} {:<8} {:>10} {}",
d.depot,
d.action,
human_bytes(d.bytes),
d.route
);
}
println!("total : {}", human_bytes(p.total_bytes));
println!("note : {}", p.note);
}
fn install_markdown(p: &InstallPlan) -> String {
let mut md = String::from("# Install Plan\n\n");
md.push_str(&format!(
"Player state:\n\n- platform: {}\n- language: {}\n- owned depots: {}\n- installed build: {}\n- target build: {}\n\n",
p.platform.as_deref().unwrap_or("any"),
p.language.as_deref().unwrap_or("any"),
if p.owned.is_empty() { "(all required)".into() } else { p.owned.join(", ") },
p.from_build.as_deref().unwrap_or("none"),
p.to_build
));
md.push_str("| Depot | Action | Download | Route |\n|---|---|---:|---|\n");
for d in &p.depots {
md.push_str(&format!(
"| {} | {} | {} | {} |\n",
d.depot,
d.action,
human_bytes(d.bytes),
d.route
));
}
md.push_str(&format!(
"\nTotal:\n **{}**\n\n> {}\n",
human_bytes(p.total_bytes),
p.note
));
md
}