diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a61c41..227d2a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,3 +75,49 @@ jobs: - name: Build Tauri app (debug, no bundle) run: npm run tauri -- build --debug --no-bundle + + windows-check: + name: Windows check + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Rust cache + uses: swatinem/rust-cache@v2 + with: + workspaces: './src-tauri -> target' + + - name: Install npm dependencies + run: npm ci + + # generate_context! (src-tauri/src/lib.rs) embeds frontendDist (../dist) + # into the binary at compile time, so the Rust steps below need dist/ to + # exist. Build the frontend first (mirrors the Linux job's ordering). The + # frontend quality checks (lint/typecheck/tests/bindings) and fmt are + # platform-independent and already covered by the Linux `check` job, so we + # don't repeat them here — this job exists to compile-check the + # Windows-only (`#[cfg(windows)]` / `cfg!(windows)`) Rust paths. + - name: Build frontend + run: npm run build + + - name: Rust check + working-directory: src-tauri + run: cargo check + + - name: Rust clippy + working-directory: src-tauri + run: cargo clippy -- -D warnings + + - name: Rust tests + working-directory: src-tauri + run: cargo test diff --git a/src-tauri/src/assistant/sandbox/profile.rs b/src-tauri/src/assistant/sandbox/profile.rs index 0d5a516..858c1f6 100644 --- a/src-tauri/src/assistant/sandbox/profile.rs +++ b/src-tauri/src/assistant/sandbox/profile.rs @@ -24,6 +24,10 @@ const ENV_DENY_EXACT: &[&str] = &[ "SUDO_ASKPASS", ]; +// Fields are consumed by the Linux (bwrap) and macOS (seatbelt) sandbox +// backends; on platforms without a sandbox (e.g. Windows -> `unsupported.rs`) +// the profile is built but never read, so allow the resulting dead_code there. +#[allow(dead_code)] #[derive(Debug, Clone)] pub struct SandboxProfile { pub workspace_root: PathBuf, @@ -111,12 +115,16 @@ impl SandboxEnv { Self { vars: filtered } } + // Read only by the Unix sandbox backends when applying the env to the + // sandboxed command; unused on platforms without a sandbox. + #[allow(dead_code)] pub fn iter(&self) -> impl Iterator { self.vars .iter() .map(|(key, value)| (key.as_str(), value.as_str())) } + #[allow(dead_code)] pub fn home(&self) -> Option<&str> { self.vars.get("HOME").map(String::as_str) } diff --git a/src-tauri/src/assistant/sandbox/runner.rs b/src-tauri/src/assistant/sandbox/runner.rs index e789690..a9e760c 100644 --- a/src-tauri/src/assistant/sandbox/runner.rs +++ b/src-tauri/src/assistant/sandbox/runner.rs @@ -26,6 +26,9 @@ pub struct SandboxCommand { pub cwd: PathBuf, pub timeout_ms: u64, pub max_output_chars: usize, + // Consumed by the Unix sandbox backends; unused on platforms without a + // sandbox (Windows runs the command unsandboxed via `unsupported.rs`). + #[allow(dead_code)] pub profile: SandboxProfile, } diff --git a/src-tauri/src/assistant/tools/local.rs b/src-tauri/src/assistant/tools/local.rs index daa1fba..ea22cf0 100644 --- a/src-tauri/src/assistant/tools/local.rs +++ b/src-tauri/src/assistant/tools/local.rs @@ -2342,11 +2342,14 @@ mod tests { } let resolved = canonicalize_requested_path("~/some/subpath").unwrap(); - // Use canonicalize on the temp dir for comparison since macOS - // symlinks /var → /private/var which would otherwise mismatch. - let expected_parent = std::fs::canonicalize(temp.path()).unwrap(); - assert!(resolved.starts_with(&expected_parent)); - assert!(resolved.ends_with("some/subpath")); + // The "~/some/subpath" target does not exist, so the product returns + // the normalized (non-canonicalized) form. Compare structurally: + // `canonicalize` would add a Windows `\\?\` verbatim prefix and resolve + // the macOS `/var`->`/private/var` symlink, both of which break a naive + // prefix match. Component-based checks are separator-agnostic. + assert!(resolved.is_absolute()); + assert!(resolved.starts_with(temp.path())); + assert!(resolved.ends_with(std::path::Path::new("some").join("subpath"))); unsafe { match prev { @@ -2390,7 +2393,9 @@ mod tests { .strip_prefix(root) .unwrap() .to_string_lossy() - .into_owned() + // Normalize Windows `\` to `/` so the assertion is + // separator-agnostic (no-op on Unix). + .replace('\\', "/") }) .collect(); @@ -2427,7 +2432,8 @@ mod tests { .strip_prefix(root) .unwrap() .to_string_lossy() - .into_owned() + // Normalize Windows `\` to `/` (no-op on Unix). + .replace('\\', "/") }) .collect(); @@ -2446,7 +2452,14 @@ mod tests { let temp = tempdir().unwrap(); let root = temp.path(); - let error = glob_allowed_paths("/etc/**/*.conf", &[grant_for(root)], 10).unwrap_err(); + // `/etc/...` is not absolute on Windows (no drive), so use a + // platform-absolute pattern that cannot intersect the temp grant. + let outside_pattern = if cfg!(windows) { + "C:/Windows/System32/**/*.conf" + } else { + "/etc/**/*.conf" + }; + let error = glob_allowed_paths(outside_pattern, &[grant_for(root)], 10).unwrap_err(); assert!(error.contains("outside the agent's allowed filesystem grants")); } diff --git a/src-tauri/src/commands/assistant.rs b/src-tauri/src/commands/assistant.rs index 5f03616..b40e235 100644 --- a/src-tauri/src/commands/assistant.rs +++ b/src-tauri/src/commands/assistant.rs @@ -1115,7 +1115,12 @@ mod tests { paths.insert(".clai/images/uuid-a.png".to_string()); paths.insert(".clai/images/uuid-b.jpg".to_string()); let files = resolve_session_image_files(root, &paths); - let mut display: Vec = files.iter().map(|p| p.display().to_string()).collect(); + // Normalize Windows `\` to `/` so the assertion is separator-agnostic + // (no-op on Unix). `root.join(p)` yields `\` joins on Windows. + let mut display: Vec = files + .iter() + .map(|p| p.display().to_string().replace('\\', "/")) + .collect(); display.sort(); assert_eq!( display, diff --git a/src-tauri/src/mcp/client.rs b/src-tauri/src/mcp/client.rs index 960902a..d69fb01 100644 --- a/src-tauri/src/mcp/client.rs +++ b/src-tauri/src/mcp/client.rs @@ -102,6 +102,10 @@ struct StdioMcpServerConnection { child: Child, } +// The variants' sizes differ enough to trip clippy::large_enum_variant only on +// Windows (platform-dependent type sizes); there are few of these per process +// and they are not hot, so allow it rather than box the larger variant. +#[allow(clippy::large_enum_variant)] enum ConnectedMcpServer { Http(RunningService), Stdio(StdioMcpServerConnection), diff --git a/src-tauri/src/providers/mod.rs b/src-tauri/src/providers/mod.rs index a5660b0..09edfff 100644 --- a/src-tauri/src/providers/mod.rs +++ b/src-tauri/src/providers/mod.rs @@ -97,7 +97,9 @@ pub(crate) fn get_host_command(cmd: &str) -> Command { } /// Common user-local binary paths to search when command isn't in PATH. -/// These are relative to the user's home directory. +/// These are relative to the user's home directory. Only used by the +/// non-Windows `command_exists` fallback (Windows relies on `where`). +#[cfg(not(target_os = "windows"))] const USER_BIN_PATHS: &[&str] = &[ ".local/bin", // Standard XDG user binaries ".bun/bin", // Bun global installs @@ -133,7 +135,9 @@ pub fn get_home_dir() -> Option { std::env::var("HOME").ok() } -/// Checks if a command exists at a specific path. +/// Checks if a command exists at a specific path. Only used by the non-Windows +/// `command_exists` fallback that probes user-local bin paths. +#[cfg(not(target_os = "windows"))] fn command_exists_at_path(path: &str) -> bool { if is_flatpak() { // Use flatpak-spawn to check file existence on host