Skip to main content

foch_cli/cli/handler/
input.rs

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/// A playset mod to repair, as `foch input repair` and bare `foch` list it.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct RepairTarget {
72	pub position: usize,
73	pub id: String,
74	pub name: String,
75	/// Why it cannot be analyzed; `None` for a working mod named explicitly.
76	pub reason: Option<String>,
77	/// The numeric Workshop id Steam can open, if it is a Workshop item.
78	pub workshop_id: Option<String>,
79}
80
81/// The mods to repair: those named by Workshop id or `#POSITION`, or every
82/// mod that cannot be analyzed when none is named.
83pub 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
131/// Open each target's Workshop page in the Steam client. Returns how many
132/// opened; targets that are not Workshop items are skipped.
133pub 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
145/// Hand a URL to the desktop's handler. Callers pass only URLs built from
146/// numeric Workshop ids.
147fn 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
169/// The current EU4 input as bare `foch` shows it first: the game, base data,
170/// each playset mod with the position and id `foch merge --exclude` takes,
171/// and every issue.
172fn 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}