1use crate::cli::arg::{
2 CheckOutputFormat, FochCliInputCommands, InputArgs, InputInspectArgs, InputRepairArgs,
3};
4use crate::cli::handler::HandlerResult;
5use foch::input::{
6 BaseDataState, Config, CurrentEu4Input, InputReadiness, InputRequest, InputResolveSummary,
7 InputSource, inspect_current_eu4_input, resolve_input_summary,
8};
9use std::io;
10use std::process::{Command, Stdio};
11
12pub fn handle_input(args: &InputArgs, config: Config) -> HandlerResult {
13 match &args.command {
14 FochCliInputCommands::Inspect(inspect_args) => handle_input_inspect(inspect_args, config),
15 FochCliInputCommands::Repair(repair_args) => handle_input_repair(repair_args),
16 }
17}
18
19fn handle_input_inspect(args: &InputInspectArgs, config: Config) -> HandlerResult {
20 let Some(source_path) = &args.source_path else {
21 let input = inspect_current_eu4_input();
22 match args.format {
23 CheckOutputFormat::Text => println!("{}", render_current_input(&input)),
24 CheckOutputFormat::Json => println!("{}", serde_json::to_string_pretty(&input)?),
25 }
26 return Ok(match input.readiness {
27 InputReadiness::Blocked => 2,
28 InputReadiness::Ready | InputReadiness::ReadyWithOmissions => 0,
29 });
30 };
31 if args.format == CheckOutputFormat::Json {
32 return Err("`--format json` describes the current EU4 input; omit INPUT_SOURCE".into());
33 }
34 let request = InputRequest::new(InputSource::from_path(source_path.clone()), config);
35 let summary = resolve_input_summary(&request)?;
36 println!("{}", render_input_summary(&summary));
37 Ok(0)
38}
39
40fn handle_input_repair(args: &InputRepairArgs) -> HandlerResult {
41 let input = inspect_current_eu4_input();
42 let targets = repair_targets(&input, &args.mods)?;
43 if targets.is_empty() {
44 println!("every mod of the current EU4 playset can be analyzed");
45 return Ok(0);
46 }
47 for target in &targets {
48 println!(
49 "#{} {} {}: {}",
50 target.position,
51 target.id,
52 target.name,
53 target.reason.as_deref().unwrap_or("selected")
54 );
55 match target.workshop_id.as_deref() {
56 Some(id) => println!(" {}", workshop_web_url(id)),
57 None => println!(" not a Workshop item; repair it outside Steam"),
58 }
59 }
60 if args.open {
61 let opened = open_workshop_pages(&targets)?;
62 println!(
63 "opened {opened} Workshop page(s) in Steam: unsubscribe and subscribe again, wait for the download, then inspect again"
64 );
65 }
66 Ok(0)
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct RepairTarget {
72 pub position: usize,
73 pub id: String,
74 pub name: String,
75 pub reason: Option<String>,
77 pub workshop_id: Option<String>,
79}
80
81pub fn repair_targets(
84 input: &CurrentEu4Input,
85 named: &[String],
86) -> Result<Vec<RepairTarget>, String> {
87 let mods = input
88 .playset
89 .as_ref()
90 .map(|playset| playset.mods.as_slice())
91 .unwrap_or_default();
92 let target = |playset_mod: &foch::input::DetectedPlaysetMod| RepairTarget {
93 position: playset_mod.position,
94 id: playset_mod.id.clone(),
95 name: playset_mod.name.clone(),
96 reason: playset_mod.source_error.clone(),
97 workshop_id: playset_mod
98 .workshop_id
99 .clone()
100 .filter(|id| !id.is_empty() && id.chars().all(|c| c.is_ascii_digit())),
101 };
102 if named.is_empty() {
103 return Ok(mods
104 .iter()
105 .filter(|playset_mod| playset_mod.source_error.is_some())
106 .map(target)
107 .collect());
108 }
109 named
110 .iter()
111 .map(|name| {
112 mods.iter()
113 .find(|playset_mod| match name.strip_prefix('#') {
114 Some(position) => position.parse() == Ok(playset_mod.position),
115 None => playset_mod.id == *name,
116 })
117 .map(target)
118 .ok_or_else(|| {
119 format!(
120 "`{name}` names no mod of the current EU4 playset; see `foch input inspect`"
121 )
122 })
123 })
124 .collect()
125}
126
127pub fn workshop_web_url(workshop_id: &str) -> String {
128 format!("https://steamcommunity.com/sharedfiles/filedetails/?id={workshop_id}")
129}
130
131pub fn open_workshop_pages(targets: &[RepairTarget]) -> io::Result<usize> {
134 let mut opened = 0;
135 for workshop_id in targets
136 .iter()
137 .filter_map(|target| target.workshop_id.as_deref())
138 {
139 open_url(&format!("steam://url/CommunityFilePage/{workshop_id}"))?;
140 opened += 1;
141 }
142 Ok(opened)
143}
144
145fn open_url(url: &str) -> io::Result<()> {
148 let mut command = if cfg!(windows) {
149 let mut command = Command::new("rundll32");
150 command.args(["url.dll,FileProtocolHandler", url]);
151 command
152 } else if cfg!(target_os = "macos") {
153 let mut command = Command::new("open");
154 command.arg(url);
155 command
156 } else {
157 let mut command = Command::new("xdg-open");
158 command.arg(url);
159 command
160 };
161 command
162 .stdin(Stdio::null())
163 .stdout(Stdio::null())
164 .stderr(Stdio::null())
165 .spawn()
166 .map(|_| ())
167}
168
169fn render_current_input(input: &CurrentEu4Input) -> String {
173 let mut lines = vec![
174 format!(
175 "readiness: {}",
176 match input.readiness {
177 InputReadiness::Ready => "ready",
178 InputReadiness::ReadyWithOmissions => "ready_with_omissions",
179 InputReadiness::Blocked => "blocked",
180 }
181 ),
182 format!(
183 "game: {} {}",
184 input.game.name,
185 input.game.version.as_deref().unwrap_or("<unknown version>")
186 ),
187 format!(
188 "base_data: {} {}",
189 match input.base_data.state {
190 BaseDataState::Ready => "ready",
191 BaseDataState::Missing => "missing",
192 BaseDataState::Stale => "stale",
193 },
194 input.base_data.detail
195 ),
196 ];
197 if let Some(playset) = &input.playset {
198 lines.push(format!(
199 "playset: {} ({} mods) {}",
200 playset.name,
201 playset.mods.len(),
202 playset.source_path.display()
203 ));
204 for playset_mod in &playset.mods {
205 let status = match &playset_mod.source_error {
206 None => "ok".to_string(),
207 Some(error) => format!("cannot_analyze: {error}"),
208 };
209 lines.push(format!(
210 " #{} {} {} [{status}]",
211 playset_mod.position, playset_mod.id, playset_mod.name
212 ));
213 }
214 } else {
215 lines.push("playset: <none>".to_string());
216 }
217 if input.issues.is_empty() {
218 lines.push("issues: none".to_string());
219 } else {
220 lines.push("issues:".to_string());
221 for issue in &input.issues {
222 lines.push(format!(" - {}: {}", issue.title, issue.detail));
223 if let Some(action) = &issue.action {
224 lines.push(format!(" action: {action}"));
225 }
226 }
227 }
228 lines.join("\n")
229}
230
231fn render_input_summary(summary: &InputResolveSummary) -> String {
232 let mut lines = vec![
233 format!("input: {}", summary.source_path.display()),
234 format!("game: {}", summary.game.key()),
235 format!(
236 "game_root: {}",
237 summary
238 .game_root
239 .as_ref()
240 .map(|path| path.display().to_string())
241 .unwrap_or_else(|| "<unresolved>".to_string())
242 ),
243 "mods:".to_string(),
244 ];
245 for mod_item in &summary.mods {
246 let display = mod_item
247 .display_name
248 .as_deref()
249 .filter(|value| !value.trim().is_empty())
250 .unwrap_or(&mod_item.mod_id);
251 let steam = mod_item
252 .steam_id
253 .as_deref()
254 .map(|value| format!(" steam_id={value}"))
255 .unwrap_or_default();
256 let root = mod_item
257 .root_path
258 .as_ref()
259 .map(|path| path.display().to_string())
260 .unwrap_or_else(|| "<missing>".to_string());
261 let descriptor = mod_item
262 .descriptor_error
263 .as_deref()
264 .map(|error| format!(" descriptor_error={error}"))
265 .unwrap_or_default();
266 lines.push(format!(
267 " - id={} name={}{} path={}{}",
268 mod_item.mod_id, display, steam, root, descriptor
269 ));
270 }
271 lines.join("\n")
272}