1#![forbid(unsafe_code)]
2
3use std::collections::BTreeSet;
4use std::ffi::OsStr;
5use std::fs;
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9use anyhow::{Context, Result, anyhow};
10use clap::{ArgAction, Parser, ValueEnum};
11use regex::Regex;
12use semver::Version;
13use serde::Serialize;
14use serde_json::json;
15use tempfile::TempDir;
16use tracing::info;
17use walkdir::WalkDir;
18
19use crate::build;
20
21#[derive(Debug, Clone, ValueEnum)]
22pub enum GuiPackKind {
23 Layout,
24 Auth,
25 Feature,
26 Skin,
27 Telemetry,
28}
29
30#[derive(Debug, Clone, Parser)]
31pub struct Args {
32 #[arg(long = "pack-kind", value_enum)]
34 pub pack_kind: GuiPackKind,
35
36 #[arg(long = "id")]
38 pub pack_id: String,
39
40 #[arg(long = "version")]
42 pub version: String,
43
44 #[arg(long = "pack-manifest-kind", default_value = "application")]
46 pub pack_manifest_kind: String,
47
48 #[arg(long = "publisher", default_value = "greentic.gui")]
50 pub publisher: String,
51
52 #[arg(long)]
54 pub name: Option<String>,
55
56 #[arg(long = "repo-url", conflicts_with_all = ["dir", "assets_dir"])]
58 pub repo_url: Option<String>,
59
60 #[arg(long, requires = "repo_url", default_value = "main")]
62 pub branch: String,
63
64 #[arg(long, conflicts_with_all = ["repo_url", "assets_dir"])]
66 pub dir: Option<PathBuf>,
67
68 #[arg(long = "assets-dir", conflicts_with_all = ["repo_url", "dir"])]
70 pub assets_dir: Option<PathBuf>,
71
72 #[arg(long = "package-dir")]
74 pub package_dir: Option<PathBuf>,
75
76 #[arg(long = "install-cmd")]
78 pub install_cmd: Option<String>,
79
80 #[arg(long = "build-cmd")]
82 pub build_cmd: Option<String>,
83
84 #[arg(long = "build-dir")]
86 pub build_dir: Option<PathBuf>,
87
88 #[arg(long = "spa")]
90 pub spa: Option<bool>,
91
92 #[arg(long = "route", action = ArgAction::Append)]
94 pub routes: Vec<String>,
95
96 #[arg(long = "routes", value_name = "ROUTES")]
98 pub routes_flat: Option<String>,
99
100 #[arg(long = "out", alias = "output", value_name = "FILE")]
102 pub out: PathBuf,
103}
104
105struct ConvertOptions {
106 pack_kind: GuiPackKind,
107 pack_id: String,
108 version: Version,
109 pack_manifest_kind: String,
110 publisher: String,
111 name: Option<String>,
112 source: Source,
113 package_dir: Option<PathBuf>,
114 install_cmd: Option<String>,
115 build_cmd: Option<String>,
116 build_dir: Option<PathBuf>,
117 spa: Option<bool>,
118 routes: Vec<RouteOverride>,
119 out: PathBuf,
120}
121
122#[derive(Debug, Clone)]
123enum Source {
124 Repo { url: String, branch: String },
125 Dir(PathBuf),
126 AssetsDir(PathBuf),
127}
128
129#[derive(Debug, Clone)]
130struct RouteOverride {
131 path: String,
132 html: PathBuf,
133}
134
135#[derive(Debug, Serialize)]
136struct Summary {
137 pack_id: String,
138 version: String,
139 pack_kind: String,
140 gui_kind: String,
141 out: String,
142 routes: Vec<String>,
143 assets_copied: usize,
144}
145
146pub async fn handle(
147 args: Args,
148 json_out: bool,
149 runtime: &crate::runtime::RuntimeContext,
150) -> Result<()> {
151 let opts = ConvertOptions::try_from(args)?;
152 let staging = TempDir::new().context("failed to create staging dir")?;
153 let staging_root = staging.path();
154 let pack_root = staging_root
155 .canonicalize()
156 .context("failed to canonicalize staging dir")?;
157
158 let mut _clone_guard: Option<TempDir> = None;
159 let source_root = match &opts.source {
160 Source::Repo { url, branch } => {
161 runtime.require_online("git clone (packc gui loveable-convert --repo-url)")?;
162 let (temp, repo_dir) = clone_repo(url, branch)?;
163 let path = repo_dir
164 .canonicalize()
165 .context("failed to canonicalize cloned repo")?;
166 _clone_guard = Some(temp);
167 path
168 }
169 Source::Dir(p) => p.canonicalize().context("failed to canonicalize --dir")?,
170 Source::AssetsDir(p) => p
171 .canonicalize()
172 .context("failed to canonicalize --assets-dir")?,
173 };
174
175 let build_root = opts
176 .package_dir
177 .as_ref()
178 .map(|p| source_root.join(p))
179 .unwrap_or_else(|| source_root.clone());
180
181 let assets_dir = match opts.source {
182 Source::AssetsDir(_) => build_root,
183 _ => {
184 runtime.require_online("install/build GUI assets")?;
185 build_assets(&build_root, &opts)?
186 }
187 };
188
189 let assets_dir = assets_dir
190 .canonicalize()
191 .with_context(|| format!("failed to canonicalize assets dir {}", assets_dir.display()))?;
192
193 let staging_assets = staging_root.join("gui").join("assets");
194 let copied = copy_assets(&assets_dir, &staging_assets)?;
195
196 let gui_manifest = build_gui_manifest(&opts, &staging_assets)?;
197 write_gui_manifest(&pack_root.join("gui").join("manifest.json"), &gui_manifest)?;
198
199 write_pack_manifest(&opts, &pack_root, copied)?;
200
201 let build_opts = build::BuildOptions {
202 pack_dir: pack_root.clone(),
203 component_out: None,
204 manifest_out: pack_root.join("dist").join("manifest.cbor"),
205 sbom_out: None,
206 gtpack_out: Some(opts.out.clone()),
207 lock_path: pack_root.join("pack.lock.cbor"),
208 bundle: build::BundleMode::Cache,
209 dry_run: false,
210 secrets_req: None,
211 default_secret_scope: None,
212 allow_oci_tags: false,
213 require_component_manifests: false,
214 no_extra_dirs: false,
215 dev: false,
216 runtime: runtime.clone(),
217 skip_update: false,
218 allow_pack_schema: false,
219 validate_extension_refs: true,
220 };
221 build::run(&build_opts).await?;
222
223 if json_out {
224 let summary = Summary {
225 pack_id: opts.pack_id.clone(),
226 version: opts.version.to_string(),
227 pack_kind: opts.pack_manifest_kind.clone(),
228 gui_kind: gui_kind_string(&opts.pack_kind),
229 out: opts.out.display().to_string(),
230 routes: extract_route_strings(&gui_manifest),
231 assets_copied: copied,
232 };
233 println!("{}", serde_json::to_string_pretty(&summary)?);
234 } else {
235 info!(
236 pack_id = %opts.pack_id,
237 version = %opts.version,
238 gui_kind = gui_kind_string(&opts.pack_kind),
239 out = %opts.out.display(),
240 assets = copied,
241 "gui pack conversion complete"
242 );
243 }
244
245 Ok(())
246}
247
248impl TryFrom<Args> for ConvertOptions {
249 type Error = anyhow::Error;
250
251 fn try_from(args: Args) -> Result<Self> {
252 if args.assets_dir.is_some() && args.package_dir.is_some() {
253 return Err(anyhow!(
254 "--package-dir cannot be combined with --assets-dir (assets are already built)"
255 ));
256 }
257
258 let source = if let Some(url) = args.repo_url {
259 Source::Repo {
260 url,
261 branch: args.branch,
262 }
263 } else if let Some(dir) = args.dir {
264 Source::Dir(dir)
265 } else if let Some(assets) = args.assets_dir {
266 Source::AssetsDir(assets)
267 } else {
268 return Err(anyhow!(
269 "one of --repo-url, --dir, or --assets-dir must be provided"
270 ));
271 };
272
273 let routes = parse_routes(&args.routes, args.routes_flat.as_deref())?;
274 let version =
275 Version::parse(&args.version).context("invalid --version (expected semver)")?;
276 let out = if args.out.is_absolute() {
277 args.out
278 } else {
279 std::env::current_dir()
280 .context("failed to resolve current dir")?
281 .join(args.out)
282 };
283
284 Ok(Self {
285 pack_kind: args.pack_kind,
286 pack_id: args.pack_id,
287 version,
288 pack_manifest_kind: args.pack_manifest_kind.to_ascii_lowercase(),
289 publisher: args.publisher,
290 name: args.name,
291 source,
292 package_dir: args.package_dir,
293 install_cmd: args.install_cmd,
294 build_cmd: args.build_cmd,
295 build_dir: args.build_dir,
296 spa: args.spa,
297 routes,
298 out,
299 })
300 }
301}
302
303fn parse_routes(explicit: &[String], flat: Option<&str>) -> Result<Vec<RouteOverride>> {
304 let mut entries = Vec::new();
305
306 for raw in explicit {
307 entries.push(parse_route_entry(raw)?);
308 }
309
310 if let Some(flat_raw) = flat {
311 for part in flat_raw.split(',') {
312 if part.trim().is_empty() {
313 continue;
314 }
315 entries.push(parse_route_entry(part.trim())?);
316 }
317 }
318
319 Ok(entries)
320}
321
322fn parse_route_entry(raw: &str) -> Result<RouteOverride> {
323 let mut parts = raw.splitn(2, ':');
324 let path = parts
325 .next()
326 .ok_or_else(|| anyhow!("invalid route entry: {}", raw))?;
327 let html = parts
328 .next()
329 .ok_or_else(|| anyhow!("route entry must be path:html => {}", raw))?;
330
331 let path = path.trim().to_string();
332 if !path.starts_with('/') {
333 return Err(anyhow!("route path must start with '/': {}", path));
334 }
335
336 let html_path = PathBuf::from(html.trim());
337 if html_path.is_absolute() {
338 return Err(anyhow!(
339 "route html path must be relative to gui/assets: {}",
340 html
341 ));
342 }
343
344 Ok(RouteOverride {
345 path,
346 html: html_path,
347 })
348}
349
350fn clone_repo(url: &str, branch: &str) -> Result<(TempDir, PathBuf)> {
351 let temp = TempDir::new().context("failed to create temp dir for clone")?;
352 let target = temp.path().join("repo");
353
354 let status = Command::new("git")
355 .arg("clone")
356 .arg("--branch")
357 .arg(branch)
358 .arg("--depth")
359 .arg("1")
360 .arg(url)
361 .arg(&target)
362 .status()
363 .with_context(|| format!("failed to execute git clone for {}", url))?;
364
365 if !status.success() {
366 return Err(anyhow!("git clone failed with status {}", status));
367 }
368
369 Ok((temp, target))
370}
371
372fn build_assets(build_root: &Path, opts: &ConvertOptions) -> Result<PathBuf> {
373 let install_cmd = opts
374 .install_cmd
375 .clone()
376 .unwrap_or_else(|| default_install_command(build_root));
377 let build_cmd = opts
378 .build_cmd
379 .clone()
380 .unwrap_or_else(|| "npm run build".to_string());
381
382 run_shell(&install_cmd, build_root, "install dependencies")?;
383 run_shell(&build_cmd, build_root, "build GUI assets")?;
384
385 if let Some(dir) = &opts.build_dir {
386 return Ok(build_root.join(dir));
387 }
388
389 let dist = build_root.join("dist");
390 if dist.is_dir() {
391 return Ok(dist);
392 }
393
394 let build = build_root.join("build");
395 if build.is_dir() {
396 return Ok(build);
397 }
398
399 Err(anyhow!(
400 "unable to detect build output; specify --build-dir"
401 ))
402}
403
404fn default_install_command(root: &Path) -> String {
405 if root.join("pnpm-lock.yaml").exists() {
406 "pnpm install".to_string()
407 } else if root.join("yarn.lock").exists() {
408 "yarn install".to_string()
409 } else {
410 "npm install".to_string()
411 }
412}
413
414fn run_shell(cmd: &str, cwd: &Path, why: &str) -> Result<()> {
415 info!(command = %cmd, cwd = %cwd.display(), "running {}", why);
416 let status = Command::new("sh")
417 .arg("-c")
418 .arg(cmd)
419 .current_dir(cwd)
420 .status()
421 .with_context(|| format!("failed to run command: {}", cmd))?;
422
423 if !status.success() {
424 return Err(anyhow!("command failed ({}) with status {}", why, status));
425 }
426
427 Ok(())
428}
429
430fn copy_assets(src: &Path, dest: &Path) -> Result<usize> {
431 let mut count = 0usize;
432 for entry in WalkDir::new(src)
433 .into_iter()
434 .filter_map(Result::ok)
435 .filter(|e| e.file_type().is_file())
436 {
437 let rel = entry
438 .path()
439 .strip_prefix(src)
440 .expect("walkdir provided prefix");
441 let target = dest.join(rel);
442 if let Some(parent) = target.parent() {
443 fs::create_dir_all(parent)
444 .with_context(|| format!("failed to create {}", parent.display()))?;
445 }
446 fs::copy(entry.path(), &target).with_context(|| {
447 format!(
448 "failed to copy {} to {}",
449 entry.path().display(),
450 target.display()
451 )
452 })?;
453 count += 1;
454 }
455
456 Ok(count)
457}
458
459fn build_gui_manifest(opts: &ConvertOptions, assets_root: &Path) -> Result<serde_json::Value> {
460 let html_files = discover_html_files(assets_root);
461 if html_files.is_empty()
462 && !matches!(opts.pack_kind, GuiPackKind::Skin | GuiPackKind::Telemetry)
463 {
464 return Err(anyhow!(
465 "no HTML files found in assets dir {}",
466 assets_root.display()
467 ));
468 }
469
470 match opts.pack_kind {
471 GuiPackKind::Layout => {
472 let entry = select_entrypoint(&html_files);
473 let spa = opts.spa.unwrap_or_else(|| infer_spa(&html_files, &entry));
474 Ok(json!({
475 "kind": "gui-layout",
476 "layout": {
477 "slots": ["header","menu","main","footer"],
478 "entrypoint_html": format!("gui/assets/{}", to_unix_path(&entry)),
479 "spa": spa,
480 "slot_selectors": {
481 "header": "#app-header",
482 "menu": "#app-menu",
483 "main": "#app-main",
484 "footer": "#app-footer"
485 }
486 }
487 }))
488 }
489 GuiPackKind::Auth => {
490 let routes = build_auth_routes(&html_files);
491 Ok(json!({
492 "kind": "gui-auth",
493 "routes": routes,
494 "ui_bindings": {
495 "login_form_selector": "#login-form",
496 "login_buttons": [
497 { "provider": "microsoft", "selector": "#login-ms" },
498 { "provider": "google", "selector": "#login-google" }
499 ]
500 }
501 }))
502 }
503 GuiPackKind::Feature => {
504 let routes = build_feature_routes(opts, &html_files);
505 let workers = detect_workers(assets_root, &html_files)?;
506 Ok(json!({
507 "kind": "gui-feature",
508 "routes": routes,
509 "digital_workers": workers,
510 "fragments": []
511 }))
512 }
513 GuiPackKind::Skin => {
514 let theme_css_path = find_theme_css(assets_root);
515 let theme_css = theme_css_path.map(|p| format!("gui/assets/{}", to_unix_path(&p)));
516 Ok(json!({
517 "kind": "gui-skin",
518 "skin": {
519 "theme_css": theme_css
520 }
521 }))
522 }
523 GuiPackKind::Telemetry => Ok(json!({
524 "kind": "gui-telemetry",
525 "telemetry": {}
526 })),
527 }
528}
529
530fn write_gui_manifest(path: &Path, value: &serde_json::Value) -> Result<()> {
531 if let Some(parent) = path.parent() {
532 fs::create_dir_all(parent)
533 .with_context(|| format!("failed to create {}", parent.display()))?;
534 }
535 let data = serde_json::to_vec_pretty(value)?;
536 fs::write(path, data).with_context(|| format!("failed to write {}", path.display()))
537}
538
539#[derive(Debug, Serialize)]
540struct PackManifestYaml<'a> {
541 pack_id: &'a str,
542 version: &'a str,
543 kind: &'a str,
544 publisher: &'a str,
545 #[serde(skip_serializing_if = "Vec::is_empty")]
546 components: Vec<()>,
547 #[serde(skip_serializing_if = "Vec::is_empty")]
548 dependencies: Vec<()>,
549 #[serde(skip_serializing_if = "Vec::is_empty")]
550 flows: Vec<()>,
551 assets: Vec<AssetEntry>,
552 #[serde(skip_serializing_if = "Option::is_none")]
553 name: Option<&'a str>,
554}
555
556#[derive(Debug, Serialize)]
557struct AssetEntry {
558 path: String,
559}
560
561fn write_pack_manifest(opts: &ConvertOptions, root: &Path, assets_copied: usize) -> Result<()> {
562 if assets_copied == 0 {
563 return Err(anyhow!("no assets copied; cannot build GUI pack"));
564 }
565
566 let mut assets = Vec::new();
567 assets.push(AssetEntry {
568 path: "gui/manifest.json".to_string(),
569 });
570
571 let assets_root = root.join("gui").join("assets");
572 for entry in WalkDir::new(&assets_root)
573 .into_iter()
574 .filter_map(Result::ok)
575 .filter(|e| e.file_type().is_file())
576 {
577 let rel = entry.path().strip_prefix(root).expect("walkdir prefix");
578 assets.push(AssetEntry {
579 path: to_unix_path(rel),
580 });
581 }
582
583 assets.sort_by(|a, b| a.path.cmp(&b.path));
584
585 let yaml = PackManifestYaml {
586 pack_id: &opts.pack_id,
587 version: &opts.version.to_string(),
588 kind: &opts.pack_manifest_kind,
589 publisher: &opts.publisher,
590 components: Vec::new(),
591 dependencies: Vec::new(),
592 flows: Vec::new(),
593 assets,
594 name: opts.name.as_deref(),
595 };
596
597 let manifest_path = root.join("pack.yaml");
598 let contents = serde_yaml_bw::to_string(&yaml)?;
599 fs::write(&manifest_path, contents)
600 .with_context(|| format!("failed to write {}", manifest_path.display()))?;
601
602 Ok(())
603}
604
605fn discover_html_files(assets_root: &Path) -> Vec<PathBuf> {
606 WalkDir::new(assets_root)
607 .into_iter()
608 .filter_map(Result::ok)
609 .filter(|e| e.file_type().is_file())
610 .filter(|e| {
611 e.path()
612 .extension()
613 .map(|ext| ext == "html")
614 .unwrap_or(false)
615 })
616 .map(|e| {
617 e.path()
618 .strip_prefix(assets_root)
619 .unwrap_or(e.path())
620 .to_path_buf()
621 })
622 .collect()
623}
624
625fn select_entrypoint(html_files: &[PathBuf]) -> PathBuf {
626 html_files
627 .iter()
628 .find(|p| p.file_name().map(|n| n == "index.html").unwrap_or(false))
629 .cloned()
630 .unwrap_or_else(|| html_files[0].clone())
631}
632
633fn infer_spa(html_files: &[PathBuf], entry: &Path) -> bool {
634 let real_pages = html_files.iter().filter(|p| is_real_page(p)).count();
635 real_pages <= 1
636 && entry
637 .file_name()
638 .map(|n| n == "index.html")
639 .unwrap_or(false)
640}
641
642fn is_real_page(path: &Path) -> bool {
643 let ignore = ["404", "robots"];
644 path.extension().map(|ext| ext == "html").unwrap_or(false)
645 && !ignore
646 .iter()
647 .any(|ig| path.file_stem().and_then(OsStr::to_str) == Some(ig))
648}
649
650fn build_auth_routes(html_files: &[PathBuf]) -> Vec<serde_json::Value> {
651 let mut routes = Vec::new();
652 let login = html_files
653 .iter()
654 .find(|p| p.file_name().and_then(OsStr::to_str) == Some("login.html"))
655 .or_else(|| html_files.first());
656
657 if let Some(login) = login {
658 routes.push(json!({
659 "path": "/login",
660 "html": format!("gui/assets/{}", to_unix_path(login)),
661 "public": true
662 }));
663 }
664
665 routes
666}
667
668fn build_feature_routes(opts: &ConvertOptions, html_files: &[PathBuf]) -> Vec<serde_json::Value> {
669 if !opts.routes.is_empty() {
670 return opts
671 .routes
672 .iter()
673 .map(|r| {
674 json!({
675 "path": r.path,
676 "html": format!("gui/assets/{}", to_unix_path(&r.html)),
677 "authenticated": true
678 })
679 })
680 .collect();
681 }
682
683 let entry = select_entrypoint(html_files);
684 let spa = opts.spa.unwrap_or_else(|| infer_spa(html_files, &entry));
685
686 let mut routes = Vec::new();
687 if spa {
688 routes.push(json!({
689 "path": "/",
690 "html": format!("gui/assets/{}", to_unix_path(&entry)),
691 "authenticated": true
692 }));
693 return routes;
694 }
695
696 for page in html_files.iter().filter(|p| is_real_page(p)) {
697 let route = route_from_path(page);
698 routes.push(json!({
699 "path": route,
700 "html": format!("gui/assets/{}", to_unix_path(page)),
701 "authenticated": true
702 }));
703 }
704
705 routes
706}
707
708fn route_from_path(path: &Path) -> String {
709 let mut parts = Vec::new();
710 if let Some(parent) = path.parent()
711 && parent != Path::new("")
712 {
713 parts.push(to_unix_path(parent));
714 }
715 if path.file_stem().and_then(OsStr::to_str) != Some("index") {
716 parts.push(
717 path.file_stem()
718 .and_then(OsStr::to_str)
719 .unwrap_or_default()
720 .to_string(),
721 );
722 }
723
724 let combined = parts.join("/");
725 if combined.is_empty() {
726 "/".to_string()
727 } else if combined.starts_with('/') {
728 combined
729 } else {
730 format!("/{}", combined)
731 }
732}
733
734fn detect_workers(assets_root: &Path, html_files: &[PathBuf]) -> Result<Vec<serde_json::Value>> {
735 let worker_re = Regex::new(r#"data-greentic-worker\s*=\s*"([^"]+)""#)?;
736 let slot_re = Regex::new(r#"data-greentic-worker-slot\s*=\s*"([^"]+)""#)?;
737 let mut seen = BTreeSet::new();
738 let mut workers = Vec::new();
739
740 for rel in html_files {
741 let abs = assets_root.join(rel);
742 let contents = fs::read_to_string(&abs).with_context(|| {
743 format!(
744 "failed to read HTML for worker detection: {}",
745 abs.display()
746 )
747 })?;
748
749 for caps in worker_re.captures_iter(&contents) {
750 let worker_id = caps
751 .get(1)
752 .map(|m| m.as_str().to_string())
753 .unwrap_or_default();
754 if worker_id.is_empty() || !seen.insert(worker_id.clone()) {
755 continue;
756 }
757
758 let slot = slot_re
759 .captures(&contents)
760 .and_then(|c| c.get(1))
761 .map(|m| m.as_str().to_string());
762
763 let selector = slot
764 .as_ref()
765 .map(|s| format!("#{}", s))
766 .unwrap_or_else(|| format!(r#"[data-greentic-worker="{}"]"#, worker_id));
767
768 workers.push(json!({
769 "id": worker_id.split('.').next_back().unwrap_or(&worker_id),
770 "worker_id": worker_id,
771 "attach": { "mode": "selector", "selector": selector },
772 "routes": ["/*"]
773 }));
774 }
775 }
776
777 Ok(workers)
778}
779
780fn extract_route_strings(manifest: &serde_json::Value) -> Vec<String> {
781 manifest
782 .get("routes")
783 .and_then(|r| r.as_array())
784 .map(|arr| {
785 arr.iter()
786 .filter_map(|r| {
787 r.get("path")
788 .and_then(|p| p.as_str())
789 .map(|s| s.to_string())
790 })
791 .collect()
792 })
793 .unwrap_or_default()
794}
795
796fn to_unix_path(path: &Path) -> String {
797 path.iter()
798 .map(|p| p.to_string_lossy())
799 .collect::<Vec<_>>()
800 .join("/")
801}
802
803fn gui_kind_string(kind: &GuiPackKind) -> String {
804 match kind {
805 GuiPackKind::Layout => "gui-layout",
806 GuiPackKind::Auth => "gui-auth",
807 GuiPackKind::Feature => "gui-feature",
808 GuiPackKind::Skin => "gui-skin",
809 GuiPackKind::Telemetry => "gui-telemetry",
810 }
811 .to_string()
812}
813
814fn find_theme_css(assets_root: &Path) -> Option<PathBuf> {
815 let candidates = ["theme.css", "styles.css"];
816 for candidate in candidates {
817 let path = assets_root.join(candidate);
818 if path.exists() {
819 return Some(PathBuf::from(candidate));
820 }
821 }
822 None
823}