Skip to main content

hematite/tools/
workspace_workflow.rs

1use std::fmt::Write as _;
2
3use regex::Regex;
4use serde::{Deserialize, Serialize};
5use serde_json::{Map, Value};
6use std::fs::{self, OpenOptions};
7use std::io::{Read, Seek, SeekFrom};
8use std::path::{Path, PathBuf};
9use std::process::Stdio;
10use std::time::{Duration, SystemTime, UNIX_EPOCH};
11
12const DEFAULT_WORKFLOW_TIMEOUT_MS: u64 = 600_000;
13const DEFAULT_VERIFY_TIMEOUT_MS: u64 = 1_800_000;
14const DEFAULT_WEBSITE_BOOT_TIMEOUT_MS: u64 = 120_000;
15const DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS: u64 = 5_000;
16const DEFAULT_WEBSITE_VALIDATE_TIMEOUT_MS: u64 = 30_000;
17const WEBSITE_LOG_TAIL_BYTES: u64 = 4_096;
18
19pub async fn run_workspace_workflow(args: &Value) -> Result<String, String> {
20    let root = require_project_workspace_root()?;
21    let workflow = required_string(args, "workflow")?;
22
23    match workflow {
24        "website_start" => start_website_server(args, &root).await,
25        "website_probe" => probe_website_server(args, &root).await,
26        "website_validate" => validate_website_server(args, &root).await,
27        "website_status" => website_server_status(args, &root).await,
28        "website_stop" => stop_website_server(args, &root).await,
29        _ => {
30            let invocation = WorkspaceInvocation::from_args(args, &root)?;
31            let output = crate::tools::shell::execute_command_in_dir(
32                &invocation.command,
33                &root,
34                invocation.timeout_ms,
35                false,
36                1000000,
37            )
38            .await?;
39
40            Ok(format!(
41                "Workspace workflow: {}\nWorkspace root: {}\nCommand: {}\n\n{}",
42                invocation.workflow_label,
43                root.display(),
44                invocation.command,
45                output.trim()
46            ))
47        }
48    }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
52struct WorkspaceInvocation {
53    workflow_label: String,
54    command: String,
55    timeout_ms: u64,
56}
57
58#[derive(Debug, Clone, PartialEq, Eq)]
59struct WebsiteLaunchPlan {
60    mode: String,
61    script: String,
62    command: String,
63    url: String,
64    framework_hint: String,
65    boot_timeout_ms: u64,
66    request_timeout_ms: u64,
67}
68
69#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
70struct WebsiteServerState {
71    label: String,
72    mode: String,
73    script: String,
74    command: String,
75    url: String,
76    framework_hint: String,
77    pid: u32,
78    log_path: String,
79    workspace_root: String,
80    started_at_epoch_ms: u64,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq)]
84struct WebsiteProbeSummary {
85    url: String,
86    status: u16,
87    content_type: Option<String>,
88    title: Option<String>,
89    body_preview: String,
90}
91
92#[derive(Debug, Clone, PartialEq, Eq)]
93struct WebsiteResponseSnapshot {
94    summary: WebsiteProbeSummary,
95    body: String,
96}
97
98impl WorkspaceInvocation {
99    fn from_args(args: &Value, root: &Path) -> Result<Self, String> {
100        let workflow = required_string(args, "workflow")?;
101        let timeout_ms = args
102            .get("timeout_ms")
103            .and_then(|value| value.as_u64())
104            .unwrap_or(default_timeout_ms(workflow));
105
106        let command = match workflow {
107            "build" => default_command_for_action(root, "build")?,
108            "test" => default_command_for_action(root, "test")?,
109            "lint" => default_command_for_action(root, "lint")?,
110            "fix" => default_command_for_action(root, "fix")?,
111            "package_script" => build_package_script_command(root, required_string(args, "name")?)?,
112            "task" => format!("task {}", required_string(args, "name")?),
113            "just" => format!("just {}", required_string(args, "name")?),
114            "make" => format!("make {}", required_string(args, "name")?),
115            "script_path" => build_script_path_command(root, required_string(args, "path")?)?,
116            "command" => required_string(args, "command")?.to_string(),
117            other => {
118                return Err(format!(
119                    "Unknown workflow '{}'. Use one of: build, test, lint, fix, package_script, task, just, make, script_path, command, website_start, website_probe, website_validate, website_status, website_stop.",
120                    other
121                ))
122            }
123        };
124
125        Ok(Self {
126            workflow_label: workflow.to_string(),
127            command,
128            timeout_ms,
129        })
130    }
131}
132
133fn require_project_workspace_root() -> Result<PathBuf, String> {
134    Ok(crate::tools::file_ops::workspace_root())
135}
136
137fn required_string<'a>(args: &'a Value, key: &str) -> Result<&'a str, String> {
138    args.get(key)
139        .and_then(|value| value.as_str())
140        .map(str::trim)
141        .filter(|value| !value.is_empty())
142        .ok_or_else(|| format!("Missing required argument: '{}'", key))
143}
144
145fn optional_string<'a>(args: &'a Value, key: &str) -> Option<&'a str> {
146    args.get(key)
147        .and_then(|value| value.as_str())
148        .map(str::trim)
149        .filter(|value| !value.is_empty())
150}
151
152fn optional_string_vec(args: &Value, key: &str) -> Vec<String> {
153    args.get(key)
154        .and_then(|value| value.as_array())
155        .into_iter()
156        .flat_map(|items| items.iter())
157        .filter_map(|value| value.as_str())
158        .map(str::trim)
159        .filter(|value| !value.is_empty())
160        .map(ToOwned::to_owned)
161        .collect()
162}
163
164fn default_timeout_ms(workflow: &str) -> u64 {
165    match workflow {
166        "build" | "test" | "lint" | "fix" => DEFAULT_VERIFY_TIMEOUT_MS,
167        "website_start" => DEFAULT_WEBSITE_BOOT_TIMEOUT_MS,
168        "website_probe" | "website_status" => DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS,
169        "website_validate" => DEFAULT_WEBSITE_VALIDATE_TIMEOUT_MS,
170        _ => DEFAULT_WORKFLOW_TIMEOUT_MS,
171    }
172}
173
174fn default_command_for_action(root: &Path, action: &str) -> Result<String, String> {
175    let profile = crate::agent::workspace_profile::load_workspace_profile(root)
176        .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(root));
177
178    match action {
179        "build" => profile
180            .build_hint
181            .ok_or_else(|| missing_workspace_command_message(action, root)),
182        "test" => profile
183            .test_hint
184            .ok_or_else(|| missing_workspace_command_message(action, root)),
185        "lint" => detect_lint_command(root),
186        "fix" => detect_fix_command(root),
187        other => Err(format!("Unsupported workspace action '{}'.", other)),
188    }
189}
190
191fn missing_workspace_command_message(action: &str, root: &Path) -> String {
192    format!(
193        "Hematite could not infer a `{}` command for the locked workspace at {}. Add a workspace verify profile in `.hematite/settings.json`, or ask for an explicit command/script instead.",
194        action,
195        root.display()
196    )
197}
198
199fn detect_lint_command(root: &Path) -> Result<String, String> {
200    if root.join("Cargo.toml").exists() {
201        Ok("cargo clippy --all-targets --all-features -- -D warnings".to_string())
202    } else if root.join("package.json").exists() {
203        Ok(format!(
204            "{} run lint --if-present",
205            detect_node_package_manager(root)
206        ))
207    } else {
208        Err(missing_workspace_command_message("lint", root))
209    }
210}
211
212fn detect_fix_command(root: &Path) -> Result<String, String> {
213    if root.join("Cargo.toml").exists() {
214        Ok("cargo fmt".to_string())
215    } else if root.join("package.json").exists() {
216        Ok(format!(
217            "{} run fix --if-present",
218            detect_node_package_manager(root)
219        ))
220    } else {
221        Err(missing_workspace_command_message("fix", root))
222    }
223}
224
225fn build_package_script_command(root: &Path, name: &str) -> Result<String, String> {
226    let package = read_package_json(root)?;
227    let has_script = package
228        .get("scripts")
229        .and_then(|value| value.get(name))
230        .is_some();
231    if !has_script {
232        return Err(format!(
233            "package.json does not define a script named `{}` in {}.",
234            name,
235            root.display()
236        ));
237    }
238
239    let package_manager = detect_node_package_manager(root);
240    let command = match package_manager.as_str() {
241        "yarn" => format!("yarn {}", name),
242        "bun" => format!("bun run {}", name),
243        manager => format!("{} run {}", manager, name),
244    };
245    Ok(command)
246}
247
248fn read_package_json(root: &Path) -> Result<Value, String> {
249    let package_json = root.join("package.json");
250    if !package_json.exists() {
251        return Err(format!(
252            "This workflow requires package.json in the locked workspace root ({}).",
253            root.display()
254        ));
255    }
256
257    let content = fs::read_to_string(&package_json)
258        .map_err(|e| format!("Failed to read {}: {}", package_json.display(), e))?;
259    serde_json::from_str(&content)
260        .map_err(|e| format!("Failed to parse {}: {}", package_json.display(), e))
261}
262
263fn package_scripts(package: &Value) -> Map<String, Value> {
264    package
265        .get("scripts")
266        .and_then(|value| value.as_object())
267        .cloned()
268        .unwrap_or_default()
269}
270
271fn build_script_path_command(root: &Path, relative_path: &str) -> Result<String, String> {
272    let candidate = root.join(relative_path);
273    let canonical_root = root
274        .canonicalize()
275        .map_err(|e| format!("Failed to resolve workspace root {}: {}", root.display(), e))?;
276    let canonical_path = candidate.canonicalize().map_err(|e| {
277        format!(
278            "Could not resolve script path `{}` from workspace root {}: {}",
279            relative_path,
280            root.display(),
281            e
282        )
283    })?;
284    if !canonical_path.starts_with(&canonical_root) {
285        return Err(format!(
286            "Script path `{}` resolves outside the locked workspace root {}.",
287            relative_path,
288            root.display()
289        ));
290    }
291
292    let display_path = normalize_relative_path(&canonical_path, &canonical_root)?;
293    let lower = display_path.to_ascii_lowercase();
294    if lower.ends_with(".ps1") {
295        Ok(format!(
296            "pwsh -ExecutionPolicy Bypass -File {}",
297            quote_command_arg(&display_path)
298        ))
299    } else if lower.ends_with(".cmd") || lower.ends_with(".bat") {
300        Ok(format!("cmd /C {}", quote_command_arg(&display_path)))
301    } else if lower.ends_with(".sh") {
302        Ok(format!("bash {}", quote_command_arg(&display_path)))
303    } else if lower.ends_with(".py") {
304        Ok(format!("python {}", quote_command_arg(&display_path)))
305    } else if lower.ends_with(".js") || lower.ends_with(".cjs") || lower.ends_with(".mjs") {
306        Ok(format!("node {}", quote_command_arg(&display_path)))
307    } else {
308        Ok(display_path)
309    }
310}
311
312fn normalize_relative_path(path: &Path, root: &Path) -> Result<String, String> {
313    let relative = path
314        .strip_prefix(root)
315        .map_err(|e| format!("Failed to normalize script path: {}", e))?;
316    Ok(format!(
317        ".{}{}",
318        std::path::MAIN_SEPARATOR,
319        relative.display()
320    ))
321}
322
323fn quote_command_arg(value: &str) -> String {
324    format!("\"{}\"", value.replace('"', "\\\""))
325}
326
327fn detect_node_package_manager(root: &Path) -> String {
328    if root.join("pnpm-lock.yaml").exists() {
329        "pnpm".to_string()
330    } else if root.join("yarn.lock").exists() {
331        "yarn".to_string()
332    } else if root.join("bun.lockb").exists() || root.join("bun.lock").exists() {
333        "bun".to_string()
334    } else {
335        "npm".to_string()
336    }
337}
338
339async fn start_website_server(args: &Value, root: &Path) -> Result<String, String> {
340    let label = optional_string(args, "label").unwrap_or("default");
341    let state_path = website_state_path(root, label);
342    if let Some(existing) = load_website_server_state(&state_path)? {
343        if is_process_alive(existing.pid).await {
344            return Err(format!(
345                "A website server labeled `{}` is already running.\nURL: {}\nPID: {}\nLog: {}\nUse workflow=website_status or workflow=website_stop first.",
346                existing.label, existing.url, existing.pid, existing.log_path
347            ));
348        }
349        let _ = fs::remove_file(&state_path);
350    }
351
352    let plan = detect_website_launch_plan(args, root)?;
353    let runtime_dir = website_runtime_dir(root);
354    fs::create_dir_all(&runtime_dir)
355        .map_err(|e| format!("Failed to create {}: {}", runtime_dir.display(), e))?;
356    let log_path = website_log_path(root, label);
357    let stdout_log = OpenOptions::new()
358        .create(true)
359        .truncate(true)
360        .write(true)
361        .open(&log_path)
362        .map_err(|e| format!("Failed to create {}: {}", log_path.display(), e))?;
363    let stderr_log = stdout_log
364        .try_clone()
365        .map_err(|e| format!("Failed to clone log handle {}: {}", log_path.display(), e))?;
366
367    let mut command = build_shell_command(&plan.command).await;
368    command
369        .current_dir(root)
370        .stdout(Stdio::from(stdout_log))
371        .stderr(Stdio::from(stderr_log));
372
373    let sandbox_root = crate::tools::file_ops::hematite_dir().join("sandbox");
374    let _ = fs::create_dir_all(&sandbox_root);
375    command.env("HOME", &sandbox_root);
376    command.env("TMPDIR", &sandbox_root);
377    command.env("CI", "1");
378    command.env("BROWSER", "none");
379
380    let mut child = command
381        .spawn()
382        .map_err(|e| format!("Failed to start website server: {}", e))?;
383    let pid = child
384        .id()
385        .ok_or_else(|| "Website server started without a visible process id.".to_string())?;
386
387    let started_at_epoch_ms = SystemTime::now()
388        .duration_since(UNIX_EPOCH)
389        .map_err(|e| format!("Clock error: {}", e))?
390        .as_millis() as u64;
391
392    let state = WebsiteServerState {
393        label: label.to_string(),
394        mode: plan.mode.clone(),
395        script: plan.script.clone(),
396        command: plan.command.clone(),
397        url: plan.url.clone(),
398        framework_hint: plan.framework_hint.clone(),
399        pid,
400        log_path: log_path.display().to_string(),
401        workspace_root: root.display().to_string(),
402        started_at_epoch_ms,
403    };
404
405    let probe = wait_for_website_readiness(
406        &mut child,
407        &plan.url,
408        plan.boot_timeout_ms,
409        plan.request_timeout_ms,
410        &log_path,
411    )
412    .await
413    .inspect_err(|_message| {
414        let _ = fs::remove_file(&state_path);
415    })?;
416
417    save_website_server_state(&state_path, &state)?;
418
419    Ok(format!(
420        "Workspace workflow: website_start\nWorkspace root: {}\nMode: {}\nLabel: {}\nScript: {}\nCommand: {}\nFramework hint: {}\nURL: {}\nPID: {}\nLog: {}\n\nReady: HTTP {}{}\n{}",
421        root.display(),
422        state.mode,
423        state.label,
424        state.script,
425        state.command,
426        state.framework_hint,
427        state.url,
428        state.pid,
429        state.log_path,
430        probe.status,
431        probe
432            .title
433            .as_ref()
434            .map(|title| format!(" ({title})"))
435            .unwrap_or_default(),
436        format_probe_details(&probe)
437    ))
438}
439
440fn detect_website_launch_plan(args: &Value, root: &Path) -> Result<WebsiteLaunchPlan, String> {
441    let package = read_package_json(root)?;
442    let scripts = package_scripts(&package);
443    let runtime_contract = load_runtime_contract(root);
444    let mode = optional_string(args, "mode")
445        .unwrap_or("dev")
446        .to_ascii_lowercase();
447    let script = if let Some(explicit) = optional_string(args, "script") {
448        explicit.to_string()
449    } else {
450        detect_website_script_name(&scripts, &mode)?
451    };
452    if !scripts.contains_key(&script) {
453        return Err(format!(
454            "package.json does not define a website script named `{}` in {}.",
455            script,
456            root.display()
457        ));
458    }
459
460    let framework_hint = infer_website_framework(&package);
461    let port = args
462        .get("port")
463        .and_then(|value| value.as_u64())
464        .and_then(|value| u16::try_from(value).ok())
465        .or_else(|| infer_website_default_port(&package, &mode));
466    let host = optional_string(args, "host").unwrap_or("127.0.0.1");
467    let url = if let Some(explicit_url) = optional_string(args, "url") {
468        normalize_http_url(explicit_url)
469    } else if let Some(url_hint) = runtime_contract
470        .as_ref()
471        .and_then(|contract| contract.local_url_hint.clone())
472    {
473        url_hint
474    } else {
475        let inferred_port = port.unwrap_or(if mode == "preview" { 4173 } else { 3000 });
476        format!("http://{}:{}/", host, inferred_port)
477    };
478
479    Ok(WebsiteLaunchPlan {
480        mode,
481        script: script.clone(),
482        command: build_package_script_command(root, &script)?,
483        url,
484        framework_hint,
485        boot_timeout_ms: args
486            .get("timeout_ms")
487            .and_then(|value| value.as_u64())
488            .unwrap_or(DEFAULT_WEBSITE_BOOT_TIMEOUT_MS),
489        request_timeout_ms: args
490            .get("request_timeout_ms")
491            .and_then(|value| value.as_u64())
492            .unwrap_or(DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS),
493    })
494}
495
496fn detect_website_script_name(scripts: &Map<String, Value>, mode: &str) -> Result<String, String> {
497    let candidates = match mode {
498        "dev" => ["dev", "start", "serve"],
499        "preview" => ["preview", "serve", "start"],
500        "start" => ["start", "serve", "dev"],
501        other => {
502            return Err(format!(
503                "Unknown website mode `{}`. Use one of: dev, preview, start.",
504                other
505            ))
506        }
507    };
508
509    candidates
510        .iter()
511        .find(|candidate| scripts.contains_key(**candidate))
512        .map(|candidate| candidate.to_string())
513        .ok_or_else(|| {
514            format!(
515                "Could not infer a website {} script from package.json. Define one of [{}], or pass `script` explicitly.",
516                mode,
517                candidates.join(", ")
518            )
519        })
520}
521
522fn infer_website_framework(package: &Value) -> String {
523    let deps = dependency_names(package);
524    let script_text = {
525        let mut s = String::new();
526        for text in package_scripts(package)
527            .into_values()
528            .filter_map(|value| value.as_str().map(|t| t.to_ascii_lowercase()))
529        {
530            if !s.is_empty() {
531                s.push('\n');
532            }
533            s.push_str(&text);
534        }
535        s
536    };
537
538    if deps.contains("next") || script_text.contains("next ") {
539        "next".to_string()
540    } else if deps.contains("vite") || script_text.contains("vite") {
541        "vite".to_string()
542    } else if deps.contains("astro") || script_text.contains("astro ") {
543        "astro".to_string()
544    } else if deps.contains("@angular/core") || script_text.contains("ng serve") {
545        "angular".to_string()
546    } else if deps.contains("gatsby") || script_text.contains("gatsby ") {
547        "gatsby".to_string()
548    } else if deps.contains("react-scripts") || script_text.contains("react-scripts") {
549        "react-scripts".to_string()
550    } else if deps.contains("@sveltejs/kit") || script_text.contains("svelte-kit") {
551        "sveltekit".to_string()
552    } else if deps.contains("nuxt") || script_text.contains("nuxt ") {
553        "nuxt".to_string()
554    } else {
555        "generic-node-site".to_string()
556    }
557}
558
559fn infer_website_default_port(package: &Value, mode: &str) -> Option<u16> {
560    match infer_website_framework(package).as_str() {
561        "vite" | "sveltekit" => Some(if mode == "preview" { 4173 } else { 5173 }),
562        "astro" => Some(4321),
563        "gatsby" => Some(8000),
564        "angular" => Some(4200),
565        "next" | "react-scripts" | "nuxt" => Some(3000),
566        _ => None,
567    }
568}
569
570fn dependency_names(package: &Value) -> std::collections::BTreeSet<String> {
571    let mut deps = std::collections::BTreeSet::new();
572    for field in ["dependencies", "devDependencies", "peerDependencies"] {
573        if let Some(map) = package.get(field).and_then(|value| value.as_object()) {
574            for name in map.keys() {
575                deps.insert(name.to_ascii_lowercase());
576            }
577        }
578    }
579    deps
580}
581
582async fn wait_for_website_readiness(
583    child: &mut tokio::process::Child,
584    url: &str,
585    boot_timeout_ms: u64,
586    request_timeout_ms: u64,
587    log_path: &Path,
588) -> Result<WebsiteProbeSummary, String> {
589    let deadline = tokio::time::Instant::now() + Duration::from_millis(boot_timeout_ms);
590    let client = reqwest::Client::builder()
591        .timeout(Duration::from_millis(request_timeout_ms))
592        .redirect(reqwest::redirect::Policy::limited(5))
593        .build()
594        .map_err(|e| format!("Failed to build readiness probe client: {}", e))?;
595
596    loop {
597        let probe_error = match probe_website_once(&client, url).await {
598            Ok(summary) => return Ok(summary),
599            Err(err) => err,
600        };
601
602        match child.try_wait() {
603            Ok(Some(status)) => {
604                return Err(format!(
605                    "Website server exited before it became ready (status: {}).\nLast probe error: {}\n{}",
606                    status,
607                    probe_error,
608                    format_log_tail_for_path("Recent log tail", Some(log_path))
609                ));
610            }
611            Ok(None) => {}
612            Err(err) => {
613                return Err(format!("Failed to inspect website server status: {}", err));
614            }
615        }
616
617        if tokio::time::Instant::now() >= deadline {
618            let _ = child.kill().await;
619            return Err(format!(
620                "Website server did not become ready within {} ms.\nLast probe error: {}\n{}",
621                boot_timeout_ms,
622                probe_error,
623                format_log_tail_for_path("Recent log tail", Some(log_path))
624            ));
625        }
626
627        tokio::time::sleep(Duration::from_millis(750)).await;
628    }
629}
630
631async fn probe_website_server(args: &Value, root: &Path) -> Result<String, String> {
632    let label = optional_string(args, "label").unwrap_or("default");
633    let state = load_website_server_state(&website_state_path(root, label))?;
634    let (url, log_path) = if let Some(state) = state {
635        (state.url, Some(state.log_path))
636    } else if let Some(url) = optional_string(args, "url") {
637        (normalize_http_url(url), None)
638    } else {
639        return Err(format!(
640            "No tracked website server labeled `{}`. Pass `url` to probe an arbitrary local site, or start one with workflow=website_start.",
641            label
642        ));
643    };
644
645    let request_timeout_ms = args
646        .get("timeout_ms")
647        .and_then(|value| value.as_u64())
648        .unwrap_or(DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS);
649    let client = reqwest::Client::builder()
650        .timeout(Duration::from_millis(request_timeout_ms))
651        .redirect(reqwest::redirect::Policy::limited(5))
652        .build()
653        .map_err(|e| format!("Failed to build probe client: {}", e))?;
654    let probe = probe_website_once(&client, &url).await.map_err(|e| {
655        if let Some(path) = log_path.as_deref() {
656            format!("{}\n{}", e, format_log_tail("Recent log tail", Some(path)))
657        } else {
658            e
659        }
660    })?;
661
662    Ok(format!(
663        "Workspace workflow: website_probe\nWorkspace root: {}\nURL: {}\n\nHTTP {}{}\n{}",
664        root.display(),
665        probe.url,
666        probe.status,
667        probe
668            .title
669            .as_ref()
670            .map(|title| format!(" ({title})"))
671            .unwrap_or_default(),
672        format_probe_details(&probe)
673    ))
674}
675
676async fn validate_website_server(args: &Value, root: &Path) -> Result<String, String> {
677    let label = optional_string(args, "label").unwrap_or("default");
678    let (base_url, log_path) = resolve_website_target(args, root, label)?;
679    let routes = default_website_routes(args, root);
680    let asset_limit = args
681        .get("asset_limit")
682        .and_then(|value| value.as_u64())
683        .unwrap_or(8)
684        .min(24) as usize;
685    let request_timeout_ms = args
686        .get("timeout_ms")
687        .and_then(|value| value.as_u64())
688        .unwrap_or(DEFAULT_WEBSITE_VALIDATE_TIMEOUT_MS);
689    let client = reqwest::Client::builder()
690        .timeout(Duration::from_millis(request_timeout_ms))
691        .redirect(reqwest::redirect::Policy::limited(5))
692        .build()
693        .map_err(|e| format!("Failed to build validation client: {}", e))?;
694
695    let mut route_lines = Vec::with_capacity(routes.len());
696    let mut asset_lines = Vec::with_capacity(routes.len());
697    let mut issues = Vec::with_capacity(routes.len());
698    let mut assets = std::collections::BTreeSet::new();
699
700    for route in &routes {
701        let route_url = resolve_website_url(&base_url, route)?;
702        match fetch_website_snapshot(&client, &route_url).await {
703            Ok(snapshot) => {
704                let summary = &snapshot.summary;
705                route_lines.push(format!(
706                    "- {} -> HTTP {}{}",
707                    route,
708                    summary.status,
709                    summary
710                        .title
711                        .as_ref()
712                        .map(|title| format!(" ({title})"))
713                        .unwrap_or_default()
714                ));
715                let content_type = summary.content_type.as_deref().unwrap_or_default();
716                if content_type.contains("text/html") {
717                    if summary.title.is_none() {
718                        issues.push(format!("Route {} returned HTML without a <title>.", route));
719                    }
720                    for asset in extract_local_asset_urls(&route_url, &snapshot.body)
721                        .into_iter()
722                        .take(asset_limit)
723                    {
724                        assets.insert(asset);
725                    }
726                }
727            }
728            Err(err) => {
729                issues.push(format!("Route {} failed validation: {}", route, err));
730            }
731        }
732    }
733
734    for asset_url in assets.iter().take(asset_limit) {
735        match probe_website_once(&client, asset_url).await {
736            Ok(summary) => asset_lines.push(format!(
737                "- {} -> HTTP {} ({})",
738                asset_url,
739                summary.status,
740                summary
741                    .content_type
742                    .as_deref()
743                    .unwrap_or("unknown content type")
744            )),
745            Err(err) => issues.push(format!("Asset {} failed validation: {}", asset_url, err)),
746        }
747    }
748
749    let result = if issues.is_empty() { "PASS" } else { "FAIL" };
750    let mut out = format!(
751        "Workspace workflow: website_validate\nWorkspace root: {}\nBase URL: {}\nRoutes checked: {}\nAssets checked: {}\nResult: {}",
752        root.display(),
753        base_url,
754        routes.len(),
755        asset_lines.len(),
756        result
757    );
758    if !route_lines.is_empty() {
759        out.push_str("\n\nRoutes\n");
760        out.push_str(&route_lines.join("\n"));
761    }
762    if !asset_lines.is_empty() {
763        out.push_str("\n\nAssets\n");
764        out.push_str(&asset_lines.join("\n"));
765    }
766    if !issues.is_empty() {
767        out.push_str("\n\nIssues\n");
768        for (i, issue) in issues.iter().enumerate() {
769            if i > 0 {
770                out.push('\n');
771            }
772            out.push_str("- ");
773            out.push_str(issue);
774        }
775    }
776    if let Some(path) = log_path.as_deref() {
777        out.push_str("\n\n");
778        out.push_str(&format_log_tail("Recent log tail", Some(path)));
779    }
780    Ok(out)
781}
782
783fn resolve_website_target(
784    args: &Value,
785    root: &Path,
786    label: &str,
787) -> Result<(String, Option<String>), String> {
788    let state = load_website_server_state(&website_state_path(root, label))?;
789    if let Some(state) = state {
790        return Ok((state.url, Some(state.log_path)));
791    }
792    if let Some(url) = optional_string(args, "url") {
793        return Ok((normalize_http_url(url), None));
794    }
795    if let Some(url_hint) = load_runtime_contract(root).and_then(|contract| contract.local_url_hint)
796    {
797        return Ok((url_hint, None));
798    }
799    Err(format!(
800        "No tracked website server labeled `{}` and no explicit url. Start the site with workflow=website_start or pass `url`.",
801        label
802    ))
803}
804
805fn default_website_routes(args: &Value, root: &Path) -> Vec<String> {
806    let mut routes = optional_string_vec(args, "routes");
807    if !routes.is_empty() {
808        return normalize_route_hints(routes);
809    }
810    if let Some(contract) = load_runtime_contract(root) {
811        routes = contract.route_hints;
812    }
813    if routes.is_empty() {
814        routes.push("/".to_string());
815    }
816    normalize_route_hints(routes)
817}
818
819fn normalize_route_hints(routes: Vec<String>) -> Vec<String> {
820    let mut normalized = std::collections::BTreeSet::new();
821    for route in routes {
822        let trimmed = route.trim();
823        if trimmed.is_empty() {
824            continue;
825        }
826        if trimmed.starts_with("http://")
827            || trimmed.starts_with("https://")
828            || trimmed.starts_with('/')
829        {
830            normalized.insert(trimmed.to_string());
831        } else {
832            normalized.insert(format!("/{}", trimmed));
833        }
834    }
835    if normalized.is_empty() {
836        normalized.insert("/".to_string());
837    }
838    normalized.into_iter().collect()
839}
840
841fn resolve_website_url(base_url: &str, route: &str) -> Result<String, String> {
842    if route.starts_with("http://") || route.starts_with("https://") {
843        return Ok(route.to_string());
844    }
845    let base = reqwest::Url::parse(base_url)
846        .map_err(|e| format!("Invalid base URL {}: {}", base_url, e))?;
847    base.join(route).map(|url| url.to_string()).map_err(|e| {
848        format!(
849            "Failed to resolve route {} against {}: {}",
850            route, base_url, e
851        )
852    })
853}
854
855async fn website_server_status(args: &Value, root: &Path) -> Result<String, String> {
856    let label = optional_string(args, "label").unwrap_or("default");
857    let state_path = website_state_path(root, label);
858    let Some(state) = load_website_server_state(&state_path)? else {
859        return Err(format!(
860            "No tracked website server labeled `{}`. Start one with workflow=website_start.",
861            label
862        ));
863    };
864
865    let alive = is_process_alive(state.pid).await;
866    let request_timeout_ms = args
867        .get("timeout_ms")
868        .and_then(|value| value.as_u64())
869        .unwrap_or(DEFAULT_WEBSITE_REQUEST_TIMEOUT_MS);
870    let client = reqwest::Client::builder()
871        .timeout(Duration::from_millis(request_timeout_ms))
872        .redirect(reqwest::redirect::Policy::limited(5))
873        .build()
874        .map_err(|e| format!("Failed to build status probe client: {}", e))?;
875    let probe = probe_website_once(&client, &state.url).await.ok();
876
877    let mut out = format!(
878        "Workspace workflow: website_status\nWorkspace root: {}\nLabel: {}\nMode: {}\nScript: {}\nCommand: {}\nFramework hint: {}\nURL: {}\nPID: {}\nAlive: {}\nLog: {}",
879        root.display(),
880        state.label,
881        state.mode,
882        state.script,
883        state.command,
884        state.framework_hint,
885        state.url,
886        state.pid,
887        if alive { "yes" } else { "no" },
888        state.log_path
889    );
890    if let Some(probe) = probe {
891        let _ = write!(
892            out,
893            "\n\nHTTP {}{}\n{}",
894            probe.status,
895            probe
896                .title
897                .as_ref()
898                .map(|title| format!(" ({title})"))
899                .unwrap_or_default(),
900            format_probe_details(&probe)
901        );
902    } else {
903        out.push_str("\n\nHTTP probe: unavailable");
904    }
905    out.push('\n');
906    out.push_str(&format_log_tail("Recent log tail", Some(&state.log_path)));
907    Ok(out)
908}
909
910async fn stop_website_server(args: &Value, root: &Path) -> Result<String, String> {
911    let label = optional_string(args, "label").unwrap_or("default");
912    let state_path = website_state_path(root, label);
913    let Some(state) = load_website_server_state(&state_path)? else {
914        return Err(format!(
915            "No tracked website server labeled `{}`. Nothing to stop.",
916            label
917        ));
918    };
919
920    let was_alive = is_process_alive(state.pid).await;
921    if was_alive {
922        kill_process(state.pid).await?;
923    }
924    let _ = fs::remove_file(&state_path);
925
926    Ok(format!(
927        "Workspace workflow: website_stop\nWorkspace root: {}\nLabel: {}\nPID: {}\nWas alive: {}\nURL: {}\nLog: {}\n\n{}",
928        root.display(),
929        state.label,
930        state.pid,
931        if was_alive { "yes" } else { "no" },
932        state.url,
933        state.log_path,
934        format_log_tail("Recent log tail", Some(&state.log_path))
935    ))
936}
937
938async fn probe_website_once(
939    client: &reqwest::Client,
940    url: &str,
941) -> Result<WebsiteProbeSummary, String> {
942    Ok(fetch_website_snapshot(client, url).await?.summary)
943}
944
945async fn fetch_website_snapshot(
946    client: &reqwest::Client,
947    url: &str,
948) -> Result<WebsiteResponseSnapshot, String> {
949    let response = client
950        .get(url)
951        .send()
952        .await
953        .map_err(|e| format!("HTTP probe failed for {}: {}", url, e))?;
954    let status = response.status();
955    let content_type = response
956        .headers()
957        .get(reqwest::header::CONTENT_TYPE)
958        .and_then(|value| value.to_str().ok())
959        .map(|value| value.to_string());
960    let body = response
961        .text()
962        .await
963        .map_err(|e| format!("Failed to read response body from {}: {}", url, e))?;
964    if !status.is_success() {
965        return Err(format!(
966            "HTTP probe returned {} for {}.",
967            status.as_u16(),
968            url
969        ));
970    }
971
972    Ok(WebsiteResponseSnapshot {
973        summary: WebsiteProbeSummary {
974            url: url.to_string(),
975            status: status.as_u16(),
976            content_type,
977            title: extract_html_title(&body),
978            body_preview: html_preview_text(&body),
979        },
980        body,
981    })
982}
983
984fn extract_html_title(body: &str) -> Option<String> {
985    use std::sync::OnceLock;
986    static RE: OnceLock<Regex> = OnceLock::new();
987    let re = RE
988        .get_or_init(|| Regex::new(r"(?is)<title[^>]*>(.*?)</title>").expect("valid title regex"));
989    re.captures(body)
990        .and_then(|captures| captures.get(1).map(|value| value.as_str()))
991        .map(compact_whitespace)
992        .filter(|title| !title.is_empty())
993}
994
995fn html_preview_text(body: &str) -> String {
996    use std::sync::OnceLock;
997    static RE: OnceLock<Regex> = OnceLock::new();
998    let strip_re = RE.get_or_init(|| {
999        Regex::new(r"(?is)<script[^>]*>.*?</script>|<style[^>]*>.*?</style>|<[^>]+>")
1000            .expect("valid strip regex")
1001    });
1002    let stripped = strip_re.replace_all(body, " ");
1003    let compact = compact_whitespace(&stripped);
1004    compact.chars().take(240).collect()
1005}
1006
1007fn compact_whitespace(input: &str) -> String {
1008    let mut result = String::with_capacity(input.len());
1009    for (i, word) in input.split_whitespace().enumerate() {
1010        if i > 0 {
1011            result.push(' ');
1012        }
1013        result.push_str(word);
1014    }
1015    result
1016}
1017
1018fn format_probe_details(probe: &WebsiteProbeSummary) -> String {
1019    let mut lines = Vec::with_capacity(3);
1020    if let Some(content_type) = probe.content_type.as_deref() {
1021        lines.push(format!("Content-Type: {}", content_type));
1022    }
1023    if let Some(title) = probe.title.as_deref() {
1024        lines.push(format!("Title: {}", title));
1025    }
1026    if !probe.body_preview.is_empty() {
1027        lines.push(format!("Body preview: {}", probe.body_preview));
1028    }
1029    if lines.is_empty() {
1030        "(no probe details)".to_string()
1031    } else {
1032        lines.join("\n")
1033    }
1034}
1035
1036fn normalize_http_url(url: &str) -> String {
1037    let trimmed = url.trim();
1038    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
1039        trimmed.to_string()
1040    } else {
1041        format!("http://{}", trimmed)
1042    }
1043}
1044
1045fn extract_local_asset_urls(page_url: &str, body: &str) -> Vec<String> {
1046    let Ok(page) = reqwest::Url::parse(page_url) else {
1047        return Vec::new();
1048    };
1049    use std::sync::OnceLock;
1050    static RE: OnceLock<Regex> = OnceLock::new();
1051    let regex = RE.get_or_init(|| {
1052        Regex::new(r#"(?is)(?:src|href)=["']([^"'#]+)["']"#).expect("valid asset regex")
1053    });
1054    let mut assets = std::collections::BTreeSet::new();
1055    for captures in regex.captures_iter(body) {
1056        let Some(raw) = captures.get(1).map(|value| value.as_str().trim()) else {
1057            continue;
1058        };
1059        let lower = raw.to_ascii_lowercase();
1060        if lower.starts_with("http://")
1061            || lower.starts_with("https://")
1062            || lower.starts_with("data:")
1063            || lower.starts_with("mailto:")
1064            || lower.starts_with("tel:")
1065            || lower.starts_with("javascript:")
1066        {
1067            continue;
1068        }
1069        if !looks_like_static_asset(raw) {
1070            continue;
1071        }
1072        if let Ok(joined) = page.join(raw) {
1073            assets.insert(joined.to_string());
1074        }
1075    }
1076    assets.into_iter().collect()
1077}
1078
1079fn looks_like_static_asset(path: &str) -> bool {
1080    let lower = path.to_ascii_lowercase();
1081    [
1082        ".css",
1083        ".js",
1084        ".mjs",
1085        ".ico",
1086        ".png",
1087        ".jpg",
1088        ".jpeg",
1089        ".svg",
1090        ".webp",
1091        ".gif",
1092        ".woff",
1093        ".woff2",
1094        ".map",
1095        ".json",
1096        ".webmanifest",
1097    ]
1098    .iter()
1099    .any(|suffix| lower.contains(suffix))
1100}
1101
1102fn load_runtime_contract(root: &Path) -> Option<crate::agent::workspace_profile::RuntimeContract> {
1103    crate::agent::workspace_profile::load_workspace_profile(root)
1104        .unwrap_or_else(|| crate::agent::workspace_profile::detect_workspace_profile(root))
1105        .runtime_contract
1106}
1107
1108fn website_runtime_dir(root: &Path) -> PathBuf {
1109    if crate::tools::file_ops::is_os_shortcut_directory(root) {
1110        crate::tools::file_ops::hematite_dir().join("website-runtime")
1111    } else {
1112        root.join(".hematite").join("website-runtime")
1113    }
1114}
1115
1116fn website_state_path(root: &Path, label: &str) -> PathBuf {
1117    website_runtime_dir(root).join(format!("{}.json", slugify_label(label)))
1118}
1119
1120fn website_log_path(root: &Path, label: &str) -> PathBuf {
1121    website_runtime_dir(root).join(format!("{}.log", slugify_label(label)))
1122}
1123
1124fn slugify_label(input: &str) -> String {
1125    let mut slug = String::with_capacity(input.len());
1126    let mut last_dash = false;
1127    for ch in input.chars() {
1128        let lower = ch.to_ascii_lowercase();
1129        if lower.is_ascii_alphanumeric() {
1130            slug.push(lower);
1131            last_dash = false;
1132        } else if !last_dash {
1133            slug.push('-');
1134            last_dash = true;
1135        }
1136    }
1137    let trimmed = slug.trim_matches('-');
1138    if trimmed.is_empty() {
1139        "default".to_string()
1140    } else {
1141        trimmed.to_string()
1142    }
1143}
1144
1145fn save_website_server_state(path: &Path, state: &WebsiteServerState) -> Result<(), String> {
1146    if let Some(parent) = path.parent() {
1147        fs::create_dir_all(parent)
1148            .map_err(|e| format!("Failed to create {}: {}", parent.display(), e))?;
1149    }
1150    let payload = serde_json::to_string_pretty(state)
1151        .map_err(|e| format!("Failed to encode website state: {}", e))?;
1152    fs::write(path, payload).map_err(|e| format!("Failed to write {}: {}", path.display(), e))
1153}
1154
1155fn load_website_server_state(path: &Path) -> Result<Option<WebsiteServerState>, String> {
1156    if !path.exists() {
1157        return Ok(None);
1158    }
1159    let raw = fs::read_to_string(path)
1160        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
1161    let state = serde_json::from_str(&raw)
1162        .map_err(|e| format!("Failed to parse {}: {}", path.display(), e))?;
1163    Ok(Some(state))
1164}
1165
1166fn format_log_tail(label: &str, path: Option<&str>) -> String {
1167    match path {
1168        Some(path) => match read_log_tail(Path::new(path)) {
1169            Ok(tail) if tail.is_empty() => format!("{}: (empty)", label),
1170            Ok(tail) => format!("{}:\n{}", label, tail),
1171            Err(err) => format!("{}: unavailable ({})", label, err),
1172        },
1173        None => format!("{}: unavailable", label),
1174    }
1175}
1176
1177fn format_log_tail_for_path(label: &str, path: Option<&Path>) -> String {
1178    match path {
1179        Some(path) => match read_log_tail(path) {
1180            Ok(tail) if tail.is_empty() => format!("{}: (empty)", label),
1181            Ok(tail) => format!("{}:\n{}", label, tail),
1182            Err(err) => format!("{}: unavailable ({})", label, err),
1183        },
1184        None => format!("{}: unavailable", label),
1185    }
1186}
1187
1188fn read_log_tail(path: &Path) -> Result<String, String> {
1189    let mut file =
1190        fs::File::open(path).map_err(|e| format!("failed to open {}: {}", path.display(), e))?;
1191    let len = file
1192        .metadata()
1193        .map_err(|e| format!("failed to inspect {}: {}", path.display(), e))?
1194        .len();
1195    let start = len.saturating_sub(WEBSITE_LOG_TAIL_BYTES);
1196    file.seek(SeekFrom::Start(start))
1197        .map_err(|e| format!("failed to seek {}: {}", path.display(), e))?;
1198    let mut buffer = String::with_capacity(WEBSITE_LOG_TAIL_BYTES as usize);
1199    file.read_to_string(&mut buffer)
1200        .map_err(|e| format!("failed to read {}: {}", path.display(), e))?;
1201    Ok(buffer.trim().to_string())
1202}
1203
1204async fn build_shell_command(command: &str) -> tokio::process::Command {
1205    #[cfg(target_os = "windows")]
1206    {
1207        let normalized = command
1208            .replace("/dev/null", "$null")
1209            .replace("1>/dev/null", "2>$null")
1210            .replace("2>/dev/null", "2>$null");
1211
1212        if which("pwsh").await {
1213            let mut cmd = tokio::process::Command::new("pwsh");
1214            cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
1215            cmd
1216        } else {
1217            let mut cmd = tokio::process::Command::new("powershell");
1218            cmd.args(["-NoProfile", "-NonInteractive", "-Command", &normalized]);
1219            cmd
1220        }
1221    }
1222    #[cfg(not(target_os = "windows"))]
1223    {
1224        let mut cmd = tokio::process::Command::new("sh");
1225        cmd.args(["-c", command]);
1226        cmd
1227    }
1228}
1229
1230#[cfg(target_os = "windows")]
1231async fn which(name: &str) -> bool {
1232    #[cfg(target_os = "windows")]
1233    let check = format!("{}.exe", name);
1234    #[cfg(not(target_os = "windows"))]
1235    let check = name;
1236
1237    tokio::process::Command::new("where")
1238        .arg(check)
1239        .stdout(Stdio::null())
1240        .stderr(Stdio::null())
1241        .status()
1242        .await
1243        .map(|status| status.success())
1244        .unwrap_or(false)
1245}
1246
1247async fn is_process_alive(pid: u32) -> bool {
1248    #[cfg(target_os = "windows")]
1249    {
1250        tokio::process::Command::new("tasklist")
1251            .args(["/FI", &format!("PID eq {}", pid)])
1252            .stdout(Stdio::piped())
1253            .stderr(Stdio::null())
1254            .output()
1255            .await
1256            .ok()
1257            .map(|output| {
1258                let text = String::from_utf8_lossy(&output.stdout);
1259                text.lines().any(|line| {
1260                    line.split_whitespace()
1261                        .any(|token| token == pid.to_string())
1262                })
1263            })
1264            .unwrap_or(false)
1265    }
1266    #[cfg(not(target_os = "windows"))]
1267    {
1268        tokio::process::Command::new("kill")
1269            .args(["-0", &pid.to_string()])
1270            .stdout(Stdio::null())
1271            .stderr(Stdio::null())
1272            .status()
1273            .await
1274            .map(|status| status.success())
1275            .unwrap_or(false)
1276    }
1277}
1278
1279async fn kill_process(pid: u32) -> Result<(), String> {
1280    #[cfg(target_os = "windows")]
1281    {
1282        let output = tokio::process::Command::new("taskkill")
1283            .args(["/PID", &pid.to_string(), "/T", "/F"])
1284            .output()
1285            .await
1286            .map_err(|e| format!("Failed to stop PID {}: {}", pid, e))?;
1287        if output.status.success() {
1288            Ok(())
1289        } else {
1290            Err(format!(
1291                "Failed to stop PID {}: {}",
1292                pid,
1293                String::from_utf8_lossy(&output.stderr).trim()
1294            ))
1295        }
1296    }
1297    #[cfg(not(target_os = "windows"))]
1298    {
1299        let status = tokio::process::Command::new("kill")
1300            .args(["-TERM", &pid.to_string()])
1301            .status()
1302            .await
1303            .map_err(|e| format!("Failed to stop PID {}: {}", pid, e))?;
1304        if status.success() {
1305            Ok(())
1306        } else {
1307            Err(format!("Failed to stop PID {}.", pid))
1308        }
1309    }
1310}
1311
1312#[cfg(test)]
1313mod tests {
1314    use super::*;
1315
1316    fn write_package(root: &Path, json: &str) {
1317        fs::write(root.join("package.json"), json).unwrap();
1318    }
1319
1320    #[test]
1321    fn package_script_uses_detected_package_manager() {
1322        let package_root = std::env::temp_dir().join(format!(
1323            "hematite-workspace-workflow-node-{}",
1324            std::process::id()
1325        ));
1326        std::fs::create_dir_all(&package_root).unwrap();
1327        std::fs::write(
1328            package_root.join("package.json"),
1329            r#"{ "scripts": { "dev": "vite" } }"#,
1330        )
1331        .unwrap();
1332        std::fs::write(package_root.join("pnpm-lock.yaml"), "").unwrap();
1333
1334        let command = build_package_script_command(&package_root, "dev").unwrap();
1335        assert_eq!(command, "pnpm run dev");
1336
1337        let _ = std::fs::remove_file(package_root.join("package.json"));
1338        let _ = std::fs::remove_file(package_root.join("pnpm-lock.yaml"));
1339        let _ = std::fs::remove_dir(package_root);
1340    }
1341
1342    #[test]
1343    fn script_path_stays_inside_workspace_root() {
1344        let script_dir = std::env::temp_dir().join(format!(
1345            "hematite-workspace-workflow-scripts-{}",
1346            std::process::id()
1347        ));
1348        std::fs::create_dir_all(script_dir.join("scripts")).unwrap();
1349        std::fs::write(script_dir.join("scripts").join("dev.ps1"), "Write-Host hi").unwrap();
1350
1351        let command = build_script_path_command(&script_dir, "scripts/dev.ps1").unwrap();
1352        assert!(command.contains("pwsh -ExecutionPolicy Bypass -File"));
1353
1354        let _ = std::fs::remove_file(script_dir.join("scripts").join("dev.ps1"));
1355        let _ = std::fs::remove_dir(script_dir.join("scripts"));
1356        let _ = std::fs::remove_dir(script_dir);
1357    }
1358
1359    #[test]
1360    fn detect_website_launch_plan_prefers_dev_script_and_vite_port() {
1361        let dir = tempfile::tempdir().unwrap();
1362        write_package(
1363            dir.path(),
1364            r#"{
1365                "scripts": { "dev": "vite", "preview": "vite preview" },
1366                "devDependencies": { "vite": "^5.0.0" }
1367            }"#,
1368        );
1369        std::fs::write(dir.path().join("pnpm-lock.yaml"), "").unwrap();
1370
1371        let plan = detect_website_launch_plan(&serde_json::json!({}), dir.path()).unwrap();
1372        assert_eq!(plan.script, "dev");
1373        assert_eq!(plan.command, "pnpm run dev");
1374        assert_eq!(plan.framework_hint, "vite");
1375        assert_eq!(plan.url, "http://127.0.0.1:5173/");
1376    }
1377
1378    #[test]
1379    fn detect_website_launch_plan_honors_preview_mode() {
1380        let dir = tempfile::tempdir().unwrap();
1381        write_package(
1382            dir.path(),
1383            r#"{
1384                "scripts": { "preview": "vite preview" },
1385                "devDependencies": { "vite": "^5.0.0" }
1386            }"#,
1387        );
1388
1389        let plan =
1390            detect_website_launch_plan(&serde_json::json!({ "mode": "preview" }), dir.path())
1391                .unwrap();
1392        assert_eq!(plan.script, "preview");
1393        assert_eq!(plan.url, "http://127.0.0.1:4173/");
1394    }
1395
1396    #[test]
1397    fn extract_html_title_and_preview_are_clean() {
1398        let html = r#"
1399            <html>
1400              <head><title>  Demo Site  </title></head>
1401              <body><h1>Hello</h1><script>ignore()</script><p>Readable preview text.</p></body>
1402            </html>
1403        "#;
1404        assert_eq!(extract_html_title(html).as_deref(), Some("Demo Site"));
1405        let preview = html_preview_text(html);
1406        assert!(preview.contains("Hello"));
1407        assert!(preview.contains("Readable preview text."));
1408        assert!(!preview.contains("ignore()"));
1409    }
1410
1411    #[test]
1412    fn extract_local_asset_urls_resolves_relative_assets() {
1413        let html = r#"
1414            <html>
1415              <head>
1416                <link rel="stylesheet" href="/assets/app.css">
1417                <script src="./main.js"></script>
1418              </head>
1419              <body>
1420                <img src="images/logo.png">
1421                <a href="https://example.com">external</a>
1422              </body>
1423            </html>
1424        "#;
1425        let assets = extract_local_asset_urls("http://127.0.0.1:5173/about/", html);
1426        assert!(assets
1427            .iter()
1428            .any(|asset| asset == "http://127.0.0.1:5173/assets/app.css"));
1429        assert!(assets
1430            .iter()
1431            .any(|asset| asset == "http://127.0.0.1:5173/about/main.js"));
1432        assert!(assets
1433            .iter()
1434            .any(|asset| asset == "http://127.0.0.1:5173/about/images/logo.png"));
1435        assert!(!assets.iter().any(|asset| asset.contains("example.com")));
1436    }
1437
1438    #[test]
1439    fn normalize_route_hints_deduplicates_and_prefixes_slashes() {
1440        let routes = normalize_route_hints(vec![
1441            "".to_string(),
1442            "pricing".to_string(),
1443            "/pricing".to_string(),
1444            "/".to_string(),
1445        ]);
1446        assert_eq!(routes, vec!["/".to_string(), "/pricing".to_string()]);
1447    }
1448
1449    #[tokio::test]
1450    async fn probe_website_once_reads_local_title() {
1451        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1452        let addr = listener.local_addr().unwrap();
1453        std::thread::spawn(move || {
1454            if let Ok((mut stream, _)) = listener.accept() {
1455                use std::io::Read;
1456                let response = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 67\r\nConnection: close\r\n\r\n<html><head><title>Probe Test</title></head><body>hello</body></html>";
1457                let mut request = [0_u8; 1024];
1458                let _ = stream.read(&mut request);
1459                use std::io::Write;
1460                let _ = stream.write_all(response);
1461            }
1462        });
1463
1464        let client = reqwest::Client::builder()
1465            .timeout(Duration::from_secs(2))
1466            .build()
1467            .unwrap();
1468        let probe = probe_website_once(&client, &format!("http://{}/", addr))
1469            .await
1470            .unwrap();
1471        assert_eq!(probe.status, 200);
1472        assert_eq!(probe.title.as_deref(), Some("Probe Test"));
1473        assert!(probe.body_preview.contains("hello"));
1474    }
1475}