steamroom_cli/commands/
diff.rs1use 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 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}