Skip to main content

steamroom_cli/commands/
diff.rs

1//! `steamroom diff`: compare two manifests for the same depot and print
2//! added, removed, and changed files.
3
4use crate::cli::DiffArgs;
5use crate::cli::OutputFormat;
6use crate::commands::shared::fetch_manifest;
7use crate::commands::shared::fmt_size;
8use crate::commands::shared::fmt_timestamp;
9use crate::errors::CliError;
10use crate::sink::JobSink;
11use std::sync::Arc;
12use steamroom::client::LoggedIn;
13use steamroom::client::SteamClient;
14use steamroom::depot::*;
15use tabled::builder::Builder as TableBuilder;
16use tabled::settings::Style;
17use tokio_util::sync::CancellationToken;
18use tracing::info;
19
20pub async fn run_diff(
21    args: DiffArgs,
22    client: SteamClient<LoggedIn>,
23    sink: Arc<dyn JobSink>,
24    _cancel: CancellationToken,
25) -> Result<(), CliError> {
26    let app_id = AppId(args.app);
27    let depot_id = DepotId(args.depot);
28    let from_id = ManifestId(args.from);
29    let to_id = ManifestId(args.to);
30    let branch = args.branch.as_deref();
31
32    info!("fetching old manifest {from_id}...");
33    let old = fetch_manifest(&client, app_id, depot_id, from_id, branch).await?;
34    info!("fetching new manifest {to_id}...");
35    let new = fetch_manifest(&client, app_id, depot_id, to_id, branch).await?;
36
37    // Build lookup maps: filename -> (size, sha)
38    let old_files: std::collections::HashMap<&str, &steamroom::depot::manifest::ManifestFile> =
39        old.files.iter().map(|f| (f.filename.as_str(), f)).collect();
40    let new_files: std::collections::HashMap<&str, &steamroom::depot::manifest::ManifestFile> =
41        new.files.iter().map(|f| (f.filename.as_str(), f)).collect();
42
43    let mut added: Vec<&steamroom::depot::manifest::ManifestFile> = Vec::new();
44    let mut removed: Vec<&steamroom::depot::manifest::ManifestFile> = Vec::new();
45    let mut changed: Vec<(
46        &steamroom::depot::manifest::ManifestFile,
47        &steamroom::depot::manifest::ManifestFile,
48    )> = Vec::new();
49
50    for (name, new_file) in &new_files {
51        match old_files.get(name) {
52            None => added.push(new_file),
53            Some(old_file) => {
54                if old_file.sha_content != new_file.sha_content || old_file.size != new_file.size {
55                    changed.push((old_file, new_file));
56                }
57            }
58        }
59    }
60    for (name, old_file) in &old_files {
61        if !new_files.contains_key(name) {
62            removed.push(old_file);
63        }
64    }
65
66    added.sort_by_key(|f| &f.filename);
67    removed.sort_by_key(|f| &f.filename);
68    changed.sort_by_key(|(_, f)| &f.filename);
69
70    if args.format == Some(OutputFormat::Json) {
71        let json = serde_json::json!({
72            "from": args.from,
73            "to": args.to,
74            "added": added.iter().map(|f| serde_json::json!({"filename": &f.filename, "size": f.size})).collect::<Vec<_>>(),
75            "removed": removed.iter().map(|f| serde_json::json!({"filename": &f.filename, "size": f.size})).collect::<Vec<_>>(),
76            "changed": changed.iter().map(|(old, new)| serde_json::json!({"filename": &new.filename, "old_size": old.size, "new_size": new.size})).collect::<Vec<_>>(),
77        });
78        sink.stdout_line(&serde_json::to_string_pretty(&json)?);
79        return Ok(());
80    }
81
82    let old_epoch = old.creation_time.unwrap_or(0) as u64;
83    let new_epoch = new.creation_time.unwrap_or(0) as u64;
84    let span = if new_epoch > old_epoch {
85        let secs = new_epoch - old_epoch;
86        let dur = jiff::SignedDuration::from_secs(secs as i64);
87        let hours = dur.as_hours();
88        let days = hours / 24;
89        if days >= 365 {
90            let years = days / 365;
91            let months = (days % 365) / 30;
92            if months > 0 {
93                format!("{years}y {months}mo")
94            } else {
95                format!("{years}y")
96            }
97        } else if days >= 30 {
98            let months = days / 30;
99            let rem = days % 30;
100            if rem > 0 {
101                format!("{months}mo {rem}d")
102            } else {
103                format!("{months}mo")
104            }
105        } else if days > 0 {
106            format!("{days}d")
107        } else {
108            format!("{hours}h")
109        }
110    } else {
111        String::new()
112    };
113
114    sink.stdout_line(&format!("Depot:  {depot_id}"));
115    sink.stdout_line(&format!("From:   {from_id} ({})", fmt_timestamp(old_epoch)));
116    sink.stdout_line(&format!("To:     {to_id} ({})", fmt_timestamp(new_epoch)));
117    if !span.is_empty() {
118        sink.stdout_line(&format!("Delta:  {span} apart"));
119    }
120    sink.stdout_line("");
121
122    if added.is_empty() && removed.is_empty() && changed.is_empty() {
123        sink.stdout_line("No differences.");
124        return Ok(());
125    }
126
127    let mut rows: Vec<[String; 3]> = Vec::new();
128
129    for f in &added {
130        rows.push([format!("+ {}", f.filename), fmt_size(f.size), String::new()]);
131    }
132    for f in &removed {
133        rows.push([format!("- {}", f.filename), fmt_size(f.size), String::new()]);
134    }
135    for (old, new) in &changed {
136        let size_diff = new.size as i64 - old.size as i64;
137        let diff_str = if size_diff > 0 {
138            format!("+{}", fmt_size(size_diff as u64))
139        } else if size_diff < 0 {
140            format!("-{}", fmt_size((-size_diff) as u64))
141        } else {
142            "content".to_string()
143        };
144        rows.push([format!("~ {}", new.filename), fmt_size(new.size), diff_str]);
145    }
146
147    let mut builder = TableBuilder::new();
148    builder.push_record(["FILE", "SIZE", "DELTA"]);
149    for r in &rows {
150        builder.push_record(r);
151    }
152    let table = builder
153        .build()
154        .with(Style::blank())
155        .with(tabled::settings::Padding::new(0, 2, 0, 0))
156        .with(
157            tabled::settings::Modify::new(tabled::settings::object::Columns::new(1..))
158                .with(tabled::settings::Alignment::right()),
159        )
160        .to_string();
161    for line in table.lines() {
162        sink.stdout_line(line);
163    }
164
165    sink.stdout_line("");
166    sink.stdout_line(&format!(
167        "{} added, {} removed, {} changed",
168        added.len(),
169        removed.len(),
170        changed.len()
171    ));
172
173    Ok(())
174}