From 4600a748453a95a2690cfd34a488c5275c65f8c3 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 11:33:35 +0100 Subject: [PATCH 01/11] refactor: switch to `camino` for UTF-8 path handling The codebase now uses `camino::Utf8Path` and `camino::Utf8PathBuf` instead of the standard library's `Path` and `PathBuf`. This ensures that paths handled by the application are valid UTF-8, which simplifies string conversions and improves interoperability with other crates and CLI output. Affected crates include `jp_attachment` and its implementations, as well as the `jp_cli` crate. Dev-dependencies have also been updated to use `camino-tempfile` for testing. Signed-off-by: Jean Mertz --- crates/jp_attachment/Cargo.toml | 1 + crates/jp_attachment/src/lib.rs | 4 +- crates/jp_attachment_bear_note/Cargo.toml | 1 + crates/jp_attachment_bear_note/src/lib.rs | 16 +++---- crates/jp_attachment_cmd_output/Cargo.toml | 3 +- crates/jp_attachment_cmd_output/src/lib.rs | 7 +-- crates/jp_attachment_file_content/Cargo.toml | 3 +- crates/jp_attachment_file_content/src/lib.rs | 21 +++++---- crates/jp_attachment_http_content/Cargo.toml | 1 + crates/jp_attachment_http_content/src/lib.rs | 7 +-- crates/jp_attachment_mcp_resources/Cargo.toml | 1 + crates/jp_attachment_mcp_resources/src/lib.rs | 5 ++- crates/jp_cli/Cargo.toml | 3 +- crates/jp_cli/src/cmd.rs | 6 +-- crates/jp_cli/src/cmd/conversation/fork.rs | 2 +- crates/jp_cli/src/cmd/init.rs | 30 +++++++++---- crates/jp_cli/src/cmd/query.rs | 16 +++---- crates/jp_cli/src/cmd/query/event.rs | 5 ++- .../jp_cli/src/cmd/query/response_handler.rs | 2 +- crates/jp_cli/src/editor.rs | 14 +++--- crates/jp_cli/src/lib.rs | 23 +++++----- crates/jp_cli/src/parser.rs | 44 +++++++++---------- 22 files changed, 119 insertions(+), 96 deletions(-) diff --git a/crates/jp_attachment/Cargo.toml b/crates/jp_attachment/Cargo.toml index 17800c01..65e999fa 100644 --- a/crates/jp_attachment/Cargo.toml +++ b/crates/jp_attachment/Cargo.toml @@ -16,6 +16,7 @@ version.workspace = true jp_mcp = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } dyn-clone = { workspace = true } dyn-hash = { workspace = true } linkme = { workspace = true } diff --git a/crates/jp_attachment/src/lib.rs b/crates/jp_attachment/src/lib.rs index 938e7060..5eaa9ad1 100644 --- a/crates/jp_attachment/src/lib.rs +++ b/crates/jp_attachment/src/lib.rs @@ -2,10 +2,10 @@ use std::{ error::Error, hash::Hasher, ops::{Deref, DerefMut}, - path::Path, }; use async_trait::async_trait; +use camino::Utf8Path; use dyn_clone::DynClone; use dyn_hash::DynHash; use jp_mcp::Client; @@ -77,7 +77,7 @@ pub trait Handler: std::fmt::Debug + DynClone + DynHash + Send + Sync { /// resources from MCP servers, if needed. async fn get( &self, - cwd: &Path, + cwd: &Utf8Path, mcp_client: Client, ) -> Result, Box>; } diff --git a/crates/jp_attachment_bear_note/Cargo.toml b/crates/jp_attachment_bear_note/Cargo.toml index dea4dcdd..4f12c90f 100644 --- a/crates/jp_attachment_bear_note/Cargo.toml +++ b/crates/jp_attachment_bear_note/Cargo.toml @@ -17,6 +17,7 @@ jp_attachment = { workspace = true } jp_mcp = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } directories = { workspace = true } quick-xml = { workspace = true, features = ["serialize"] } rusqlite = { workspace = true, features = ["bundled", "array", "vtab"] } diff --git a/crates/jp_attachment_bear_note/src/lib.rs b/crates/jp_attachment_bear_note/src/lib.rs index 3e9ccd24..5a4a6454 100644 --- a/crates/jp_attachment_bear_note/src/lib.rs +++ b/crates/jp_attachment_bear_note/src/lib.rs @@ -1,11 +1,7 @@ -use std::{ - collections::BTreeSet, - error::Error, - path::{Path, PathBuf}, - rc::Rc, -}; +use std::{collections::BTreeSet, error::Error, rc::Rc}; use async_trait::async_trait; +use camino::{Utf8Path, Utf8PathBuf}; use directories::BaseDirs; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, percent_decode_str, @@ -135,11 +131,11 @@ impl Handler for BearNotes { async fn get( &self, - _: &Path, + _: &Utf8Path, _: Client, ) -> Result, Box> { let db = get_database_path()?; - trace!(db = %db.display(), "Connecting to Bear database."); + trace!(db = %db, "Connecting to Bear database."); let conn = Connection::open(db)?; let mut attachments = vec![]; @@ -271,7 +267,7 @@ fn get_notes(query: &Query, conn: &Connection) -> Result, Box Result> { +fn get_database_path() -> Result> { let path = BaseDirs::new() .ok_or("Could not find base directories")? .home_dir() @@ -281,7 +277,7 @@ fn get_database_path() -> Result> { return Err(format!("Missing Bear SQLite database at {}", path.display()).into()); } - Ok(path) + path.try_into().map_err(Into::into) } #[cfg(test)] diff --git a/crates/jp_attachment_cmd_output/Cargo.toml b/crates/jp_attachment_cmd_output/Cargo.toml index d8635a10..2d38b6d2 100644 --- a/crates/jp_attachment_cmd_output/Cargo.toml +++ b/crates/jp_attachment_cmd_output/Cargo.toml @@ -17,15 +17,16 @@ jp_attachment = { workspace = true } jp_mcp = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } duct = { workspace = true } quick-xml = { workspace = true, features = ["serialize"] } serde = { workspace = true } url = { workspace = true } [dev-dependencies] +camino-tempfile = { workspace = true } indexmap = { workspace = true } indoc = { workspace = true } -tempfile = { workspace = true } test-log = { workspace = true } tokio = { workspace = true } diff --git a/crates/jp_attachment_cmd_output/src/lib.rs b/crates/jp_attachment_cmd_output/src/lib.rs index 5fc63b0f..12af08d7 100644 --- a/crates/jp_attachment_cmd_output/src/lib.rs +++ b/crates/jp_attachment_cmd_output/src/lib.rs @@ -1,6 +1,7 @@ -use std::{collections::BTreeSet, error::Error, path::Path}; +use std::{collections::BTreeSet, error::Error}; use async_trait::async_trait; +use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, percent_decode_str, percent_encode_str, typetag, @@ -103,7 +104,7 @@ impl Handler for Commands { async fn get( &self, - root: &Path, + root: &Utf8Path, _: Client, ) -> Result, Box> { let mut attachments = vec![]; @@ -272,7 +273,7 @@ mod tests { .collect(), ); - let root = tempfile::tempdir().unwrap(); + let root = camino_tempfile::tempdir().unwrap(); let path = root.path(); std::fs::create_dir_all(path.join("dir")).unwrap(); std::fs::write(path.join("file1"), "").unwrap(); diff --git a/crates/jp_attachment_file_content/Cargo.toml b/crates/jp_attachment_file_content/Cargo.toml index d7bfe1cb..e6b28a24 100644 --- a/crates/jp_attachment_file_content/Cargo.toml +++ b/crates/jp_attachment_file_content/Cargo.toml @@ -17,6 +17,7 @@ jp_attachment = { workspace = true } jp_mcp = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } crossbeam-channel = { workspace = true, features = ["std"] } glob = { workspace = true } ignore = { workspace = true } @@ -26,8 +27,8 @@ tracing = { workspace = true } url = { workspace = true } [dev-dependencies] +camino-tempfile = { workspace = true } indexmap = { workspace = true } -tempfile = { workspace = true } test-log = { workspace = true } [lints] diff --git a/crates/jp_attachment_file_content/src/lib.rs b/crates/jp_attachment_file_content/src/lib.rs index 2ecfd18a..784f74e6 100644 --- a/crates/jp_attachment_file_content/src/lib.rs +++ b/crates/jp_attachment_file_content/src/lib.rs @@ -1,6 +1,7 @@ -use std::{borrow::Cow, collections::BTreeSet, error::Error, fs, path::Path}; +use std::{borrow::Cow, collections::BTreeSet, error::Error, fs}; use async_trait::async_trait; +use camino::Utf8Path; use glob::Pattern; use ignore::{WalkBuilder, WalkState, overrides::OverrideBuilder}; use jp_attachment::{ @@ -76,7 +77,7 @@ impl Handler for FileContent { async fn get( &self, - cwd: &Path, + cwd: &Utf8Path, _: Client, ) -> Result, Box> { debug!(id = self.scheme(), "Getting file attachment contents."); @@ -159,7 +160,9 @@ impl Handler for FileContent { let Ok(entry) = entry else { return WalkState::Continue; }; - let path = entry.path(); + let Some(path) = Utf8Path::from_path(entry.path()) else { + return WalkState::Continue; + }; if path.is_dir() { return WalkState::Continue; } @@ -181,7 +184,7 @@ impl Handler for FileContent { } /// If the pattern is a directory, add it recursively. -fn sanitize_pattern<'a>(mut pattern: &'a str, cwd: &Path) -> Cow<'a, str> { +fn sanitize_pattern<'a>(mut pattern: &'a str, cwd: &Utf8Path) -> Cow<'a, str> { if pattern.starts_with('/') { pattern = &pattern[1..]; } @@ -197,10 +200,10 @@ fn sanitize_pattern<'a>(mut pattern: &'a str, cwd: &Path) -> Cow<'a, str> { } } -fn build_attachment(path: &Path, cwd: &Path) -> Option { +fn build_attachment(path: &Utf8Path, cwd: &Utf8Path) -> Option { let Ok(rel) = path.strip_prefix(cwd) else { warn!( - path = %path.display(), + path = %path, "Attachment path outside of working directory, skipping." ); @@ -210,13 +213,13 @@ fn build_attachment(path: &Path, cwd: &Path) -> Option { let content = match fs::read_to_string(path) { Ok(content) => content, Err(error) => { - warn!(path = %rel.display(), %error, "Failed to read attachment."); + warn!(path = %rel, %error, "Failed to read attachment."); return None; } }; Some(Attachment { - source: rel.to_string_lossy().to_string(), + source: rel.to_string(), content, ..Default::default() }) @@ -285,9 +288,9 @@ mod pat { #[cfg(test)] mod tests { + use camino_tempfile::tempdir; use glob::Pattern; use indexmap::IndexMap; - use tempfile::tempdir; use url::Url; use super::*; diff --git a/crates/jp_attachment_http_content/Cargo.toml b/crates/jp_attachment_http_content/Cargo.toml index 5ec73e41..cbedfc2d 100644 --- a/crates/jp_attachment_http_content/Cargo.toml +++ b/crates/jp_attachment_http_content/Cargo.toml @@ -17,6 +17,7 @@ jp_attachment = { workspace = true } jp_mcp = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } reqwest = { workspace = true } serde = { workspace = true } tracing = { workspace = true } diff --git a/crates/jp_attachment_http_content/src/lib.rs b/crates/jp_attachment_http_content/src/lib.rs index b568534d..6e3c1b11 100644 --- a/crates/jp_attachment_http_content/src/lib.rs +++ b/crates/jp_attachment_http_content/src/lib.rs @@ -1,6 +1,7 @@ -use std::{collections::BTreeSet, error::Error, marker::PhantomData, path::Path}; +use std::{collections::BTreeSet, error::Error, marker::PhantomData}; use async_trait::async_trait; +use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; @@ -62,7 +63,7 @@ impl Handler for HttpContent { async fn get( &self, - _: &Path, + _: &Utf8Path, _: Client, ) -> Result, Box> { debug!(id = "http", "Getting http attachment contents."); @@ -95,7 +96,7 @@ impl Handler for HttpContent { async fn get( &self, - _: &Path, + _: &Utf8Path, _: Client, ) -> Result, Box> { debug!(id = "https", "Getting https attachment contents."); diff --git a/crates/jp_attachment_mcp_resources/Cargo.toml b/crates/jp_attachment_mcp_resources/Cargo.toml index 008d828c..069df8b3 100644 --- a/crates/jp_attachment_mcp_resources/Cargo.toml +++ b/crates/jp_attachment_mcp_resources/Cargo.toml @@ -17,6 +17,7 @@ jp_attachment = { workspace = true } jp_mcp = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } quick-xml = { workspace = true, features = ["serialize"] } serde = { workspace = true } url = { workspace = true, features = ["serde"] } diff --git a/crates/jp_attachment_mcp_resources/src/lib.rs b/crates/jp_attachment_mcp_resources/src/lib.rs index 2dd0ba17..2085f139 100644 --- a/crates/jp_attachment_mcp_resources/src/lib.rs +++ b/crates/jp_attachment_mcp_resources/src/lib.rs @@ -1,6 +1,7 @@ -use std::{collections::BTreeSet, error::Error, path::Path}; +use std::{collections::BTreeSet, error::Error}; use async_trait::async_trait; +use camino::Utf8Path; use jp_attachment::{ Attachment, BoxedHandler, HANDLERS, Handler, distributed_slice, linkme, typetag, }; @@ -72,7 +73,7 @@ impl Handler for McpResources { async fn get( &self, - _: &Path, + _: &Utf8Path, client: Client, ) -> Result, Box> { let mut attachments = vec![]; diff --git a/crates/jp_cli/Cargo.toml b/crates/jp_cli/Cargo.toml index bf5cc169..92ff64b1 100644 --- a/crates/jp_cli/Cargo.toml +++ b/crates/jp_cli/Cargo.toml @@ -38,6 +38,7 @@ jp_workspace = { workspace = true } async-stream = { workspace = true } bat = { workspace = true, features = ["regex-onig"] } +camino = { workspace = true } clap = { workspace = true, features = [ "color", "derive", @@ -89,11 +90,11 @@ which = { workspace = true, features = ["real-sys"] } [dev-dependencies] assert_matches = { workspace = true } +camino-tempfile = { workspace = true } indoc = { workspace = true } insta = { workspace = true, features = ["toml"] } pretty_assertions = { workspace = true, features = ["std"] } serial_test = { workspace = true } -tempfile = { workspace = true } test-log = { workspace = true } [lints] diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 38cb164f..5e0e5fae 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -524,7 +524,7 @@ impl From for Error { Config(error) => return error.into(), NotDir(path) => [ ("message", "Path is not a directory.".into()), - ("path", path.to_string_lossy().into()), + ("path", path.to_string().into()), ] .into(), MissingStorage => [("message", "Missing storage directory".into())].into(), @@ -569,12 +569,12 @@ impl From for Error { Error::Config(error) => return error.into(), Error::NotDir(path) => [ ("message", "Path is not a directory.".into()), - ("path", path.to_string_lossy().into()), + ("path", path.to_string().into()), ] .into(), Error::NotSymlink(path) => [ ("message", "Path is not a symlink.".into()), - ("path", path.to_string_lossy().into()), + ("path", path.to_string().into()), ] .into(), }; diff --git a/crates/jp_cli/src/cmd/conversation/fork.rs b/crates/jp_cli/src/cmd/conversation/fork.rs index 24abd0c3..f01280fb 100644 --- a/crates/jp_cli/src/cmd/conversation/fork.rs +++ b/crates/jp_cli/src/cmd/conversation/fork.rs @@ -88,6 +88,7 @@ mod tests { use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind}; use assert_matches::assert_matches; + use camino_tempfile::tempdir; use jp_config::AppConfig; use jp_conversation::{ Conversation, ConversationEvent, ConversationStream, @@ -95,7 +96,6 @@ mod tests { }; use jp_printer::Printer; use jp_workspace::Workspace; - use tempfile::tempdir; use time::macros::utc_datetime; use tokio::runtime::Runtime; diff --git a/crates/jp_cli/src/cmd/init.rs b/crates/jp_cli/src/cmd/init.rs index ccad5216..07484c46 100644 --- a/crates/jp_cli/src/cmd/init.rs +++ b/crates/jp_cli/src/cmd/init.rs @@ -1,32 +1,46 @@ -use std::{env, fs, io::Write as _, path::PathBuf, str::FromStr as _}; +use std::{ + env::{self, current_dir}, + fs, + io::Write as _, + str::FromStr as _, +}; +use camino::{FromPathBufError, Utf8PathBuf}; use clean_path::Clean as _; use crossterm::style::Stylize as _; use duct::cmd; use jp_config::{ - PartialAppConfig, + AppConfig, Config, PartialAppConfig, PartialConfig as _, conversation::tool::RunMode, model::id::{ModelIdConfig, Name, PartialModelIdConfig, ProviderId}, }; use jp_printer::Printer; use jp_workspace::Workspace; +use schematic::schema::{SchemaGenerator, TemplateOptions, TomlTemplateRenderer}; use crate::{DEFAULT_STORAGE_DIR, Output, ctx::IntoPartialAppConfig}; #[derive(Debug, clap::Args)] pub(crate) struct Init { /// Path to initialize the workspace at. Defaults to the current directory. - path: Option, + path: Option, } impl Init { + #[expect(unused_assignments)] pub(crate) fn run(&self, printer: &Printer) -> Output { - let cwd = std::env::current_dir()?; - let mut root = self + let cwd: Utf8PathBuf = std::env::current_dir()? + .try_into() + .map_err(FromPathBufError::into_io_error)?; + + let mut root: Utf8PathBuf = self .path .clone() - .unwrap_or_else(|| PathBuf::from(".")) - .clean(); + .unwrap_or_else(|| Utf8PathBuf::from(".")) + .into_std_path_buf() + .clean() + .try_into() + .map_err(FromPathBufError::into_io_error)?; if !root.is_absolute() { root = cwd.join(root); @@ -71,7 +85,7 @@ impl Init { let loc = if root == cwd { "current directory".to_owned() } else { - root.to_string_lossy().bold().to_string() + root.to_string().bold().to_string() }; Ok(format!("Initialized workspace at {loc}").into()) diff --git a/crates/jp_cli/src/cmd/query.rs b/crates/jp_cli/src/cmd/query.rs index 9c4bd377..1a7f74ee 100644 --- a/crates/jp_cli/src/cmd/query.rs +++ b/crates/jp_cli/src/cmd/query.rs @@ -8,11 +8,11 @@ use std::{ fmt::Write as _, fs, io::{self, BufRead as _, IsTerminal}, - path::{Path, PathBuf}, sync::Arc, time::Duration, }; +use camino::{Utf8Path, Utf8PathBuf}; use clap::{ArgAction, builder::TypedValueParser as _}; use event::StreamEventHandler; use futures::StreamExt as _; @@ -428,8 +428,8 @@ impl Query { &self, stream: &mut ConversationStream, config: &AppConfig, - root: &Path, - ) -> Result<(Option, PartialAppConfig)> { + root: &Utf8Path, + ) -> Result<(Option, PartialAppConfig)> { // If replaying, remove all events up-to-and-including the last // `ChatRequest` event, which we'll replay. // @@ -542,8 +542,8 @@ impl Query { stream: &mut ConversationStream, piped: bool, config: &AppConfig, - root: &Path, - ) -> Result<(Option, PartialAppConfig)> { + root: &Utf8Path, + ) -> Result<(Option, PartialAppConfig)> { // If there is no query provided, but the user explicitly requested not // to edit the query, we populate the query with a default message, // since most LLM providers do not support empty queries. @@ -582,7 +582,7 @@ impl Query { cfg: &AppConfig, signals: &mut SignalRx, mcp_client: &jp_mcp::Client, - root: PathBuf, + root: Utf8PathBuf, is_tty: bool, turn_state: &mut TurnState, thread: &mut Thread, @@ -863,7 +863,7 @@ impl Query { event: std::result::Result, cfg: &AppConfig, mcp_client: &jp_mcp::Client, - root: PathBuf, + root: Utf8PathBuf, is_tty: bool, signals: &mut SignalRx, turn_state: &mut TurnState, @@ -1368,7 +1368,7 @@ fn apply_reasoning( fn cleanup( ctx: &mut Ctx, last_active_conversation_id: ConversationId, - query_file_path: Option<&Path>, + query_file_path: Option<&Utf8Path>, ) -> Result { let conversation_id = ctx.workspace.active_conversation_id(); diff --git a/crates/jp_cli/src/cmd/query/event.rs b/crates/jp_cli/src/cmd/query/event.rs index 967fe469..a6aa251b 100644 --- a/crates/jp_cli/src/cmd/query/event.rs +++ b/crates/jp_cli/src/cmd/query/event.rs @@ -1,5 +1,6 @@ -use std::{env, fmt::Write, fs, path::Path, time}; +use std::{env, fmt::Write, fs, time}; +use camino::Utf8Path; use crossterm::style::Stylize as _; use indexmap::{IndexMap, IndexSet}; use jp_config::{ @@ -111,7 +112,7 @@ impl StreamEventHandler { &mut self, cfg: &AppConfig, mcp_client: &jp_mcp::Client, - root: &Path, + root: &Utf8Path, is_tty: bool, turn_state: &mut TurnState, call: ToolCallRequest, diff --git a/crates/jp_cli/src/cmd/query/response_handler.rs b/crates/jp_cli/src/cmd/query/response_handler.rs index 6039801f..bd5d08de 100644 --- a/crates/jp_cli/src/cmd/query/response_handler.rs +++ b/crates/jp_cli/src/cmd/query/response_handler.rs @@ -1,4 +1,4 @@ -use std::{fs, path::PathBuf, sync::Arc, time::Duration}; +use std::{fs, sync::Arc, time::Duration}; use crossterm::style::{Color, Stylize as _}; use jp_config::style::{LinkStyle, StyleConfig}; diff --git a/crates/jp_cli/src/editor.rs b/crates/jp_cli/src/editor.rs index a289fb43..3993fa2b 100644 --- a/crates/jp_cli/src/editor.rs +++ b/crates/jp_cli/src/editor.rs @@ -3,10 +3,10 @@ mod parser; use std::{ fs::{self, OpenOptions}, io::{Read as _, Write as _}, - path::{Path, PathBuf}, str::FromStr, }; +use camino::{Utf8Path, Utf8PathBuf}; use duct::Expression; use jp_config::{ AppConfig, PartialAppConfig, ToPartial as _, model::parameters::PartialReasoningConfig, @@ -57,7 +57,7 @@ pub(crate) struct Options { cmd: Expression, /// The working directory to use. - cwd: Option, + cwd: Option, /// The initial content to use. content: Option, @@ -78,7 +78,7 @@ impl Options { /// Add a working directory to the editor options. #[must_use] - pub(crate) fn with_cwd(mut self, cwd: impl Into) -> Self { + pub(crate) fn with_cwd(mut self, cwd: impl Into) -> Self { self.cwd = Some(cwd.into()); self } @@ -99,7 +99,7 @@ impl Options { } pub(crate) struct RevertFileGuard { - path: Option, + path: Option, orig: String, exists: bool, } @@ -153,7 +153,7 @@ impl Drop for RevertFileGuard { /// (in other words, `content` is ignored). /// /// When the editor is closed, the contents are returned. -pub(crate) fn open(path: PathBuf, options: Options) -> Result<(String, RevertFileGuard)> { +pub(crate) fn open(path: Utf8PathBuf, options: Options) -> Result<(String, RevertFileGuard)> { let Options { cmd, cwd, @@ -220,12 +220,12 @@ pub(crate) fn open(path: PathBuf, options: Options) -> Result<(String, RevertFil /// Open an editor for the user to input or edit text using a file in the workspace pub(crate) fn edit_query( config: &AppConfig, - root: &Path, + root: &Utf8Path, stream: &ConversationStream, query: &str, cmd: Expression, config_error: Option<&str>, -) -> Result<(String, PathBuf, PartialAppConfig)> { +) -> Result<(String, Utf8PathBuf, PartialAppConfig)> { let query_file_path = root.join(QUERY_FILENAME); let existing_content = fs::read_to_string(&query_file_path).unwrap_or_default(); let mut doc = QueryDocument::try_from(existing_content.as_str()).unwrap_or_default(); diff --git a/crates/jp_cli/src/lib.rs b/crates/jp_cli/src/lib.rs index 893c0cf4..9cc046ee 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -17,6 +17,7 @@ use std::{ time::Duration, }; +use camino::{FromPathBufError, Utf8PathBuf, absolute_utf8}; use clap::{ ArgAction, Parser, builder::{BoolValueParser, TypedValueParser as _}, @@ -192,7 +193,7 @@ impl FromStr for KeyValueOrPath { #[derive(Debug, Clone)] pub(crate) enum WorkspaceIdOrPath { Id(jp_workspace::Id), - Path(PathBuf), + Path(Utf8PathBuf), } impl FromStr for WorkspaceIdOrPath { @@ -200,7 +201,7 @@ impl FromStr for WorkspaceIdOrPath { fn from_str(s: &str) -> Result { if PathBuf::from(s).exists() { - return Ok(Self::Path(PathBuf::from(s))); + return Ok(Self::Path(Utf8PathBuf::from(s))); } Ok(Self::Id(jp_workspace::Id::from_str(s)?)) @@ -409,7 +410,7 @@ fn load_partial_config( ) -> Result { // Load all partials in different file locations, the first loaded file // having the lowest precedence. - let partials = load_partial_configs_from_files(workspace, std::env::current_dir().ok())?; + let partials = load_partial_configs_from_files(workspace, absolute_utf8(".").ok())?; // Load all partials, merging later partials over earlier ones, unless one // of the partials set `inherit = false`, then later partials are ignored. @@ -495,7 +496,7 @@ fn load_cli_cfg_args( fn load_partial_configs_from_files( workspace: Option<&Workspace>, - cwd: Option, + cwd: Option, ) -> Result> { let mut partials = vec![]; @@ -546,7 +547,7 @@ fn load_partial_configs_from_files( /// Find the workspace for the current directory. fn load_workspace(workspace: Option<&WorkspaceIdOrPath>) -> Result { let cwd = match workspace { - None => std::env::current_dir()?, + None => absolute_utf8(".")?, Some(WorkspaceIdOrPath::Path(path)) => path.clone(), // TODO: Centralize this in a new `UserStorage` struct. @@ -562,18 +563,20 @@ fn load_workspace(workspace: Option<&WorkspaceIdOrPath>) -> Result { }) .ok_or(jp_workspace::Error::MissingStorage)? .join("storage") - .canonicalize()?, + .canonicalize()? + .try_into() + .map_err(FromPathBufError::into_io_error)?, }; - trace!(cwd = %cwd.display(), "Finding workspace."); + trace!(cwd = %cwd, "Finding workspace."); let root = Workspace::find_root(cwd, DEFAULT_STORAGE_DIR).ok_or(cmd::Error::from(format!( "Could not locate workspace. Use `{}` to create a new workspace.", "jp init".bold().yellow() )))?; - trace!(root = %root.display(), "Found workspace root."); + trace!(root = %root, "Found workspace root."); let storage = root.join(DEFAULT_STORAGE_DIR); - trace!(storage = %storage.display(), "Initializing workspace storage."); + trace!(storage = %storage, "Initializing workspace storage."); let id = jp_workspace::Id::load(&storage) .transpose() @@ -586,7 +589,7 @@ fn load_workspace(workspace: Option<&WorkspaceIdOrPath>) -> Result { let workspace = Workspace::new_with_id(root, id) .persisted_at(&storage) - .inspect(|ws| info!(workspace = %ws.root().display(), "Using existing workspace."))?; + .inspect(|ws| info!(workspace = %ws.root(), "Using existing workspace."))?; workspace.id().store(&storage)?; diff --git a/crates/jp_cli/src/parser.rs b/crates/jp_cli/src/parser.rs index ca1e305d..1272c854 100644 --- a/crates/jp_cli/src/parser.rs +++ b/crates/jp_cli/src/parser.rs @@ -1,9 +1,6 @@ -use std::{ - convert::Infallible, - env::current_dir, - path::{Path, PathBuf}, -}; +use std::convert::Infallible; +use camino::{FromPathBufError, Utf8Path, Utf8PathBuf, absolute_utf8}; use clean_path::Clean as _; use relative_path::RelativePathBuf; use tracing::trace; @@ -18,7 +15,7 @@ pub(crate) enum AttachmentUrlOrPath { } impl AttachmentUrlOrPath { - pub fn parse(&self, root: Option<&Path>) -> Result { + pub fn parse(&self, root: Option<&Utf8Path>) -> Result { let path = match &self { AttachmentUrlOrPath::Url(url) => return Ok(url.clone()), AttachmentUrlOrPath::Path(path) => path, @@ -38,44 +35,43 @@ impl AttachmentUrlOrPath { // // If `root` is `None`, then we allow absolute paths, otherwise we // assume the context is a workspace and we only allow relative paths. - let mut path = PathBuf::from(path); + let mut path = Utf8PathBuf::from(path); if let Some(root) = root { if path.is_relative() { - let Ok(cwd) = current_dir() else { - return Err(Error::Attachment(format!( - "Attachment path is relative, but the current directory could not be \ - determined: {}", - path.display() - ))); - }; - - path = cwd.join(path); + path = absolute_utf8(&path).map_err(|error| { + Error::Attachment(format!( + "Attachment path {path} is relative, but the current directory could not \ + be determined: {error}", + )) + })?; } if !path.exists() { return Err(Error::Attachment(format!( - "Attachment path does not exist: {}", - path.display() + "Attachment path does not exist: {path}", ))); } - let p = path.clean(); + let p: Utf8PathBuf = path + .as_std_path() + .clean() + .try_into() + .map_err(FromPathBufError::into_io_error)?; + let Ok(p) = p.strip_prefix(root) else { return Err(Error::Attachment(format!( - "Attachment path must be relative to the workspace: {}", - path.display() + "Attachment path must be relative to the workspace: {path}", ))); }; path = p.to_path_buf(); } else if !path.exists() { return Err(Error::Attachment(format!( - "Attachment path does not exist: {}", - path.display() + "Attachment path does not exist: {path}", ))); } - Url::parse(&format!("file:{}{exclude}", path.display())).map_err(Into::into) + Url::parse(&format!("file:{path}{exclude}")).map_err(Into::into) } } From fd5c3fe6c2ecc9a0c9a4b46ebbb45acce4d02bf5 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:05:55 +0100 Subject: [PATCH 02/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- Cargo.lock | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4c307164..d3548f70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2018,6 +2018,7 @@ name = "jp_attachment" version = "0.1.0" dependencies = [ "async-trait", + "camino", "dyn-clone", "dyn-hash", "jp_mcp", @@ -2033,6 +2034,7 @@ name = "jp_attachment_bear_note" version = "0.1.0" dependencies = [ "async-trait", + "camino", "directories", "indoc", "jp_attachment", @@ -2050,6 +2052,8 @@ name = "jp_attachment_cmd_output" version = "0.1.0" dependencies = [ "async-trait", + "camino", + "camino-tempfile", "duct", "indexmap", "indoc", @@ -2057,7 +2061,6 @@ dependencies = [ "jp_mcp", "quick-xml", "serde", - "tempfile", "test-log", "tokio", "url", @@ -2068,6 +2071,8 @@ name = "jp_attachment_file_content" version = "0.1.0" dependencies = [ "async-trait", + "camino", + "camino-tempfile", "crossbeam-channel", "glob", "ignore", @@ -2075,7 +2080,6 @@ dependencies = [ "jp_attachment", "jp_mcp", "serde", - "tempfile", "test-log", "tokio", "tracing", @@ -2087,6 +2091,7 @@ name = "jp_attachment_http_content" version = "0.1.0" dependencies = [ "async-trait", + "camino", "jp_attachment", "jp_mcp", "reqwest", @@ -2100,6 +2105,7 @@ name = "jp_attachment_mcp_resources" version = "0.1.0" dependencies = [ "async-trait", + "camino", "jp_attachment", "jp_mcp", "quick-xml", @@ -2114,6 +2120,8 @@ dependencies = [ "assert_matches", "async-stream", "bat", + "camino", + "camino-tempfile", "clap", "clean-path", "comfy-table", @@ -2159,7 +2167,6 @@ dependencies = [ "serde_json", "serial_test", "strip-ansi-escapes", - "tempfile", "termimad", "test-log", "thiserror 2.0.16", @@ -2178,6 +2185,8 @@ name = "jp_config" version = "0.1.0" dependencies = [ "assert_matches", + "camino", + "camino-tempfile", "clean-path", "directories", "duct", @@ -2197,7 +2206,6 @@ dependencies = [ "serde_json5", "serde_yaml", "serial_test", - "tempfile", "test-log", "thiserror 2.0.16", "toml 0.9.7", @@ -2260,6 +2268,7 @@ dependencies = [ "async-anthropic", "async-stream", "async-trait", + "camino", "crossterm", "duct", "duct_sh", @@ -2358,6 +2367,8 @@ name = "jp_storage" version = "0.1.0" dependencies = [ "ahash", + "camino", + "camino-tempfile", "jp_config", "jp_conversation", "jp_id", @@ -2365,7 +2376,6 @@ dependencies = [ "rayon", "serde", "serde_json", - "tempfile", "test-log", "thiserror 2.0.16", "time", @@ -2432,6 +2442,8 @@ dependencies = [ name = "jp_workspace" version = "0.1.0" dependencies = [ + "camino", + "camino-tempfile", "directories", "jp_config", "jp_conversation", @@ -2439,7 +2451,6 @@ dependencies = [ "jp_storage", "jp_tombmap", "serde", - "tempfile", "test-log", "thiserror 2.0.16", "time", From d98649087bf55696b2a97eec8005b128aab862f6 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:19:02 +0100 Subject: [PATCH 03/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd/init.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/crates/jp_cli/src/cmd/init.rs b/crates/jp_cli/src/cmd/init.rs index 07484c46..97988667 100644 --- a/crates/jp_cli/src/cmd/init.rs +++ b/crates/jp_cli/src/cmd/init.rs @@ -1,22 +1,16 @@ -use std::{ - env::{self, current_dir}, - fs, - io::Write as _, - str::FromStr as _, -}; +use std::{env, fs, io::Write as _, str::FromStr as _}; use camino::{FromPathBufError, Utf8PathBuf}; use clean_path::Clean as _; use crossterm::style::Stylize as _; use duct::cmd; use jp_config::{ - AppConfig, Config, PartialAppConfig, PartialConfig as _, + PartialAppConfig, conversation::tool::RunMode, model::id::{ModelIdConfig, Name, PartialModelIdConfig, ProviderId}, }; use jp_printer::Printer; use jp_workspace::Workspace; -use schematic::schema::{SchemaGenerator, TemplateOptions, TomlTemplateRenderer}; use crate::{DEFAULT_STORAGE_DIR, Output, ctx::IntoPartialAppConfig}; From d6a35e3b7ee0c8c59b85304f0d53290ede64bf25 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:23:21 +0100 Subject: [PATCH 04/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_workspace/Cargo.toml | 3 +- crates/jp_workspace/src/error.rs | 8 +++-- crates/jp_workspace/src/id.rs | 4 ++- crates/jp_workspace/src/lib.rs | 58 +++++++++++++++----------------- 4 files changed, 38 insertions(+), 35 deletions(-) diff --git a/crates/jp_workspace/Cargo.toml b/crates/jp_workspace/Cargo.toml index e73bfdc2..e7bfb70a 100644 --- a/crates/jp_workspace/Cargo.toml +++ b/crates/jp_workspace/Cargo.toml @@ -19,9 +19,10 @@ jp_id = { workspace = true } jp_storage = { workspace = true } jp_tombmap = { workspace = true } +camino = { workspace = true } +camino-tempfile = { workspace = true } directories = { workspace = true } serde = { workspace = true } -tempfile = { workspace = true } thiserror = { workspace = true } time = { workspace = true } tracing = { workspace = true } diff --git a/crates/jp_workspace/src/error.rs b/crates/jp_workspace/src/error.rs index c2b1ac63..1c65e612 100644 --- a/crates/jp_workspace/src/error.rs +++ b/crates/jp_workspace/src/error.rs @@ -1,5 +1,4 @@ -use std::path::PathBuf; - +use camino::Utf8PathBuf; use jp_conversation::ConversationId; pub(crate) type Result = std::result::Result; @@ -22,7 +21,7 @@ pub enum Error { MissingHome, #[error("Path is not a directory: {0}")] - NotDir(PathBuf), + NotDir(Utf8PathBuf), #[error("{0} not found: {1}")] NotFound(&'static str, String), @@ -35,6 +34,9 @@ pub enum Error { #[error("Conversation error: {0}")] Conversation(#[from] jp_conversation::Error), + + #[error(transparent)] + Io(#[from] std::io::Error), } impl Error { diff --git a/crates/jp_workspace/src/id.rs b/crates/jp_workspace/src/id.rs index 6f15f752..8a37970a 100644 --- a/crates/jp_workspace/src/id.rs +++ b/crates/jp_workspace/src/id.rs @@ -14,6 +14,8 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; +use camino::Utf8Path; + use crate::{Error, Result}; static ID_CHARS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; @@ -47,7 +49,7 @@ impl Id { } /// Store the globally unique workspace ID to the given root path. - pub fn store(&self, storage: &Path) -> io::Result<()> { + pub fn store(&self, storage: &Utf8Path) -> io::Result<()> { fs::write( storage.join(ID_FILE), format!("{ID_PREAMBLE}\n{}\n", self.0), diff --git a/crates/jp_workspace/src/lib.rs b/crates/jp_workspace/src/lib.rs index f151fc05..7a95c93c 100644 --- a/crates/jp_workspace/src/lib.rs +++ b/crates/jp_workspace/src/lib.rs @@ -8,13 +8,9 @@ mod error; mod id; mod state; -use std::{ - cell::OnceCell, - iter, - path::{Path, PathBuf}, - sync::Arc, -}; +use std::{cell::OnceCell, iter, sync::Arc}; +use camino::{FromPathBufError, Utf8Path, Utf8PathBuf}; pub use error::Error; use error::Result; pub use id::Id; @@ -33,7 +29,7 @@ pub struct Workspace { /// The root directory of the workspace. /// /// This differs from the storage's root directory. - root: PathBuf, + root: Utf8PathBuf, /// The globally unique ID of the workspace. id: id::Id, @@ -61,7 +57,7 @@ pub struct Workspace { impl Workspace { /// Find the [`Workspace`] root by walking up the directory tree. #[must_use] - pub fn find_root(mut current_dir: PathBuf, storage_dir: &str) -> Option { + pub fn find_root(mut current_dir: Utf8PathBuf, storage_dir: &str) -> Option { if storage_dir.is_empty() { return None; } @@ -79,14 +75,14 @@ impl Workspace { } /// Creates a new workspace with the given root directory. - pub fn new(root: impl AsRef) -> Self { + pub fn new(root: impl Into) -> Self { Self::new_with_id(root, id::Id::new()) } /// Creates a new workspace with the given root directory and ID. - pub fn new_with_id(root: impl AsRef, id: id::Id) -> Self { - let root = root.as_ref().to_path_buf(); - trace!(root = %root.display(), id = %id, "Initializing Workspace."); + pub fn new_with_id(root: impl Into, id: id::Id) -> Self { + let root = root.into(); + trace!(root = %root, id = %id, "Initializing Workspace."); Self { root, @@ -99,13 +95,13 @@ impl Workspace { /// Get the root path of the workspace. #[must_use] - pub fn root(&self) -> &Path { + pub fn root(&self) -> &Utf8Path { &self.root } /// Enable persistence for the workspace at the given (absolute) path. - pub fn persisted_at(mut self, path: &Path) -> Result { - trace!(path = %path.display(), "Enabling workspace persistence."); + pub fn persisted_at(mut self, path: &Utf8Path) -> Result { + trace!(path = %path, "Enabling workspace persistence."); self.disable_persistence = false; self.storage = Some(Storage::new(path)?); @@ -123,8 +119,7 @@ impl Workspace { let name = self .root .file_name() - .ok_or_else(|| Error::NotDir(self.root.clone()))? - .to_string_lossy(); + .ok_or_else(|| Error::NotDir(self.root.clone()))?; self.storage = self .storage @@ -146,14 +141,14 @@ impl Workspace { /// Returns the path to the storage directory, if persistence is enabled. #[must_use] - pub fn storage_path(&self) -> Option<&Path> { + pub fn storage_path(&self) -> Option<&Utf8Path> { self.storage.as_ref().map(Storage::path) } /// Returns the path to the user storage directory, if persistence is /// enabled, and user storage is configured. #[must_use] - pub fn user_storage_path(&self) -> Option<&Path> { + pub fn user_storage_path(&self) -> Option<&Utf8Path> { self.storage.as_ref().and_then(Storage::user_storage_path) } @@ -261,7 +256,7 @@ impl Workspace { &self.state.local.active_conversation, )?; - info!(path = %self.root.display(), "Persisted state."); + info!(path = %self.root, "Persisted state."); Ok(()) } @@ -288,7 +283,7 @@ impl Workspace { &self.state.local.active_conversation, )?; - info!(path = %self.root.display(), "Persisted active conversation."); + info!(path = %self.root, "Persisted active conversation."); Ok(()) } @@ -661,19 +656,22 @@ fn maybe_init_events<'a>( } } -pub fn user_data_dir() -> Result { - Ok(directories::ProjectDirs::from("", "", APPLICATION) +pub fn user_data_dir() -> Result { + directories::ProjectDirs::from("", "", APPLICATION) .ok_or(Error::MissingHome)? .data_local_dir() - .to_path_buf()) + .to_path_buf() + .try_into() + .map_err(FromPathBufError::into_io_error) + .map_err(Into::into) } #[cfg(test)] mod tests { use std::{collections::HashMap, fs, time::Duration}; + use camino_tempfile::tempdir; use jp_storage::{CONVERSATIONS_DIR, METADATA_FILE, value::read_json}; - use tempfile::tempdir; use test_log::test; use time::{UtcDateTime, macros::utc_datetime}; @@ -806,7 +804,7 @@ mod tests { #[test] fn test_workspace_conversations() { - let mut workspace = Workspace::new(PathBuf::new()); + let mut workspace = Workspace::new(Utf8PathBuf::new()); assert_eq!(workspace.conversations().count(), 1); // Default conversation let id = ConversationId::default(); @@ -824,7 +822,7 @@ mod tests { #[test] fn test_workspace_get_conversation() { - let mut workspace = Workspace::new(PathBuf::new()); + let mut workspace = Workspace::new(Utf8PathBuf::new()); assert!(workspace.state.local.conversations.is_empty()); let id = ConversationId::try_from(UtcDateTime::now() - Duration::from_secs(1)).unwrap(); @@ -844,7 +842,7 @@ mod tests { #[test] fn test_workspace_create_conversation() { - let mut workspace = Workspace::new(PathBuf::new()); + let mut workspace = Workspace::new(Utf8PathBuf::new()); assert!(workspace.state.local.conversations.is_empty()); let conversation = Conversation::default(); @@ -864,7 +862,7 @@ mod tests { #[test] fn test_workspace_remove_conversation() { - let mut workspace = Workspace::new(PathBuf::new()); + let mut workspace = Workspace::new(Utf8PathBuf::new()); assert!(workspace.state.local.conversations.is_empty()); let id = ConversationId::try_from(UtcDateTime::now() - Duration::from_secs(1)).unwrap(); @@ -886,7 +884,7 @@ mod tests { #[test] fn test_workspace_cannot_remove_active_conversation() { - let mut workspace = Workspace::new(PathBuf::new()); + let mut workspace = Workspace::new(Utf8PathBuf::new()); assert!(workspace.state.local.conversations.is_empty()); let active_id = workspace From f40c9347aba69c795b86f0142272e4a34f8d314a Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:40:59 +0100 Subject: [PATCH 05/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd.rs | 1 + crates/jp_storage/src/lib.rs | 130 ++++++++++++++++----------------- crates/jp_storage/src/value.rs | 4 +- 3 files changed, 68 insertions(+), 67 deletions(-) diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 5e0e5fae..38064b5e 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -521,6 +521,7 @@ impl From for Error { let metadata: Vec<(&str, Value)> = match error { Conversation(error) => return error.into(), Storage(error) => return error.into(), + Io(error) => return error.into(), Config(error) => return error.into(), NotDir(path) => [ ("message", "Path is not a directory.".into()), diff --git a/crates/jp_storage/src/lib.rs b/crates/jp_storage/src/lib.rs index 324c9d98..2c92e7ca 100644 --- a/crates/jp_storage/src/lib.rs +++ b/crates/jp_storage/src/lib.rs @@ -1,15 +1,10 @@ pub mod error; pub mod value; -use std::{ - cell::OnceCell, - fs, - io::BufReader, - iter, - path::{Path, PathBuf}, -}; +use std::{cell::OnceCell, fs, io::BufReader, iter}; use ahash::{HashMap, HashMapExt}; +use camino::{Utf8DirEntry, Utf8Path, Utf8PathBuf}; pub use error::Error; use jp_conversation::{Conversation, ConversationId, ConversationStream, ConversationsMetadata}; use jp_id::Id as _; @@ -30,7 +25,7 @@ pub const CONVERSATIONS_DIR: &str = "conversations"; #[derive(Debug)] pub struct Storage { /// The path to the original storage directory. - root: PathBuf, + root: Utf8PathBuf, /// The path to the user storage directory. /// @@ -38,22 +33,22 @@ pub struct Storage { /// that are tied to the current user. /// /// If unset, user storage is disabled. - user: Option, + user: Option, } impl Storage { /// Creates a new Storage instance by creating a temporary directory and /// copying the contents of `root` into it. - pub fn new(root: impl Into) -> Result { + pub fn new(root: impl Into) -> Result { // Create root storage directory, if needed. - let root: PathBuf = root.into(); + let root: Utf8PathBuf = root.into(); if root.exists() { if !root.is_dir() { return Err(Error::NotDir(root)); } } else { fs::create_dir_all(&root)?; - trace!(path = %root.display(), "Created storage directory."); + trace!(path = %root, "Created storage directory."); } Ok(Self { root, user: None }) @@ -61,7 +56,7 @@ impl Storage { pub fn with_user_storage( mut self, - root: &Path, + root: &Utf8Path, name: impl Into, id: impl Into, ) -> Result { @@ -72,9 +67,9 @@ impl Storage { // Create user storage directory, if needed. if root.exists() - && let Some(mut existing_path) = fs::read_dir(root)?.find_map(|entry| { - let path = entry.ok()?.path(); - path.to_string_lossy().ends_with(&id).then_some(path) + && let Some(mut existing_path) = root.read_dir_utf8()?.find_map(|entry| { + let path = entry.ok()?.into_path(); + path.to_string().ends_with(&id).then_some(path) }) { if !existing_path.is_dir() { @@ -89,8 +84,8 @@ impl Storage { { let new_path = existing_path.with_file_name(dirname); trace!( - old = %existing_path.display(), - new = %new_path.display(), + old = %existing_path, + new = %new_path, "Renaming existing user storage directory to match new name." ); fs::rename(&existing_path, &new_path)?; @@ -113,7 +108,7 @@ impl Storage { path = existing_path; } else { fs::create_dir_all(&path)?; - trace!(path = %path.display(), "Created user storage directory."); + trace!(path = %path, "Created user storage directory."); } // Create reference back to workspace storage. @@ -142,13 +137,13 @@ impl Storage { /// Returns the path to the storage directory. #[must_use] - pub fn path(&self) -> &Path { + pub fn path(&self) -> &Utf8Path { &self.root } /// Returns the path to the user storage directory, if configured. #[must_use] - pub fn user_storage_path(&self) -> Option<&Path> { + pub fn user_storage_path(&self) -> Option<&Utf8Path> { self.user.as_deref() } @@ -161,7 +156,7 @@ impl Storage { pub fn load_conversations_metadata(&self) -> Result { let root = self.user.as_deref().unwrap_or(self.root.as_path()); let metadata_path = root.join(CONVERSATIONS_DIR).join(METADATA_FILE); - trace!(path = %metadata_path.display(), "Loading user conversations metadata."); + trace!(path = %metadata_path, "Loading user conversations metadata."); if !metadata_path.exists() { return Ok(ConversationsMetadata::default()); @@ -212,7 +207,7 @@ impl Storage { let (id, mut conversation) = load_conversation_metadata(&entry)?; conversation.user = Some(root) == self.user.as_ref(); (conversation.events_count, conversation.last_event_at) = - load_count_and_timestamp_events(&entry.path()).unwrap_or((0, None)); + load_count_and_timestamp_events(entry.path()).unwrap_or((0, None)); Some((id, conversation)) }) @@ -280,8 +275,8 @@ impl Storage { let user_conversations_dir = user.join(CONVERSATIONS_DIR); trace!( - global = %conversations_dir.display(), - user = %user_conversations_dir.display(), + global = conversations_dir.as_str(), + user = user_conversations_dir.as_str(), "Persisting conversations." ); @@ -346,11 +341,11 @@ impl Storage { for dir in [&conversations_dir, &user_conversations_dir] { let mut deleted = Vec::new(); - for entry in dir.read_dir()?.flatten() { + for entry in dir.read_dir_utf8()?.flatten() { let path = entry.path(); let dir_matches_id = path.file_name().is_some_and(|v| { removed_ids.iter().any(|d| { - let file_name = v.to_string_lossy(); + let file_name = v.to_string(); let removed_id = d.target_id(); file_name == *removed_id || file_name.starts_with(&format!("{removed_id}-")) @@ -377,7 +372,10 @@ impl Storage { ) -> Result<()> { let root = self.user.as_deref().unwrap_or(self.root.as_path()); let metadata_path = root.join(CONVERSATIONS_DIR).join(METADATA_FILE); - trace!(path = %metadata_path.display(), "Persisting user conversations metadata."); + trace!( + path = metadata_path.as_str(), + "Persisting user conversations metadata." + ); write_json(&metadata_path, metadata)?; @@ -401,7 +399,7 @@ impl Storage { return None; } - let path = entry.path(); + let path = entry.into_path(); let expiring_ts = get_expiring_timestamp(&path)?; if expiring_ts > UtcDateTime::now() { return None; @@ -412,7 +410,7 @@ impl Storage { .for_each(|path| { if let Err(error) = fs::remove_dir_all(&path) { warn!( - path = path.display().to_string(), + path = path.as_str(), error = error.to_string(), "Failed to remove ephemeral conversation." ); @@ -422,7 +420,7 @@ impl Storage { } } -fn load_count_and_timestamp_events(root: &Path) -> Option<(usize, Option)> { +fn load_count_and_timestamp_events(root: &Utf8Path) -> Option<(usize, Option)> { #[derive(serde::Deserialize)] struct RawEvent { timestamp: Box, @@ -435,7 +433,11 @@ fn load_count_and_timestamp_events(root: &Path) -> Option<(usize, Option = match serde_json::from_reader(reader) { Ok(events) => events, Err(error) => { - warn!(%error, path = %path.display(), "Error parsing JSON event file."); + warn!( + error = error.to_string(), + path = path.as_str(), + "Error parsing JSON event file." + ); return None; } }; @@ -458,7 +460,7 @@ fn load_count_and_timestamp_events(root: &Path) -> Option<(usize, Option Option { +fn get_expiring_timestamp(root: &Utf8Path) -> Option { #[derive(serde::Deserialize)] struct RawConversation { expires_at: Option>, @@ -471,7 +473,11 @@ fn get_expiring_timestamp(root: &Path) -> Option { let conversation: RawConversation = match serde_json::from_reader(reader) { Ok(conversation) => conversation, Err(error) => { - warn!(%error, path = %path.display(), "Error parsing JSON metadata file."); + warn!( + error = error.to_string(), + path = path.as_str(), + "Error parsing JSON metadata file." + ); return None; } }; @@ -485,29 +491,25 @@ fn get_expiring_timestamp(root: &Path) -> Option { UtcDateTime::parse(&ts[1..ts.len() - 1], &fmt).ok() } -fn dir_entries(path: &Path) -> impl Iterator { - fs::read_dir(path) +fn dir_entries(path: &Utf8Path) -> impl Iterator { + path.read_dir_utf8() .into_iter() .flatten() .filter_map(std::result::Result::ok) } -fn find_conversation_dir_path(root: &Path, id: &ConversationId) -> Option { - fs::read_dir(root.join(CONVERSATIONS_DIR)) +fn find_conversation_dir_path(root: &Utf8Path, id: &ConversationId) -> Option { + root.join(CONVERSATIONS_DIR) + .read_dir_utf8() .ok() .into_iter() .flatten() .filter_map(std::result::Result::ok) - .find(|entry| { - entry - .file_name() - .to_str() - .is_some_and(|v| v.starts_with(&id.to_dirname(None))) - }) - .map(|entry| entry.path()) + .find(|entry| entry.file_name().starts_with(&id.to_dirname(None))) + .map(Utf8DirEntry::into_path) } -fn load_conversation_metadata(entry: &fs::DirEntry) -> Option<(ConversationId, Conversation)> { +fn load_conversation_metadata(entry: &Utf8DirEntry) -> Option<(ConversationId, Conversation)> { let conversation_id = load_conversation_id_from_entry(entry)?; let path = entry.path(); @@ -519,7 +521,7 @@ fn load_conversation_metadata(entry: &fs::DirEntry) -> Option<(ConversationId, C Err(error) => { warn!( %error, - path = metadata_path.to_string_lossy().to_string(), + path = metadata_path.to_string(), "Failed to load conversation metadata. Skipping." ); return None; @@ -530,18 +532,12 @@ fn load_conversation_metadata(entry: &fs::DirEntry) -> Option<(ConversationId, C Some((conversation_id, conversation)) } -fn load_conversation_id_from_entry(entry: &fs::DirEntry) -> Option { +fn load_conversation_id_from_entry(entry: &Utf8DirEntry) -> Option { if !entry.file_type().ok()?.is_dir() { return None; } - let file_name = entry.file_name(); - let Some(dir_name) = file_name.to_str() else { - warn!(path = ?entry.path(), "Skipping directory with invalid name."); - return None; - }; - - ConversationId::try_from_dirname(dir_name) + ConversationId::try_from_dirname(entry.file_name()) .inspect_err(|error| { warn!( %error, @@ -554,23 +550,23 @@ fn load_conversation_id_from_entry(entry: &fs::DirEntry) -> Option Result<()> { // Gather all possible conversation directory names let mut dirs = vec![]; for conversations_dir in &[workspace_conversations_dir, user_conversations_dir] { let pat = id.to_dirname(None); dirs.push(conversations_dir.join(&pat)); - for entry in fs::read_dir(conversations_dir).ok().into_iter().flatten() { - let path = entry?.path(); + for entry in conversations_dir.read_dir_utf8().ok().into_iter().flatten() { + let path = entry?.into_path(); if !path.is_dir() { continue; } if path .file_name() - .is_none_or(|v| !v.to_string_lossy().starts_with(&format!("{pat}-"))) + .is_none_or(|v| !v.starts_with(&format!("{pat}-"))) { continue; } @@ -594,7 +590,11 @@ fn remove_unused_conversation_dirs( Ok(()) } -fn remove_deleted(root: &Path, dir: &Path, deleted: impl Iterator) -> Result<()> { +fn remove_deleted( + root: &Utf8Path, + dir: &Utf8Path, + deleted: impl Iterator, +) -> Result<()> { for entry in deleted { let mut path = dir.join(entry); if path.is_file() { @@ -603,7 +603,7 @@ fn remove_deleted(root: &Path, dir: &Path, deleted: impl Iterator(path: &Path) -> Result { +pub fn read_json(path: &Utf8Path) -> Result { let file = fs::File::open(path)?; let reader = BufReader::new(file); serde_json::from_reader(reader).map_err(Into::into) } -pub fn write_json(path: &Path, value: &T) -> Result<()> { +pub fn write_json(path: &Utf8Path, value: &T) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } From 0098663d06fe61d36d984fb715d726471dcaae22 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:45:31 +0100 Subject: [PATCH 06/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_storage/Cargo.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/jp_storage/Cargo.toml b/crates/jp_storage/Cargo.toml index ccfe05b0..68cc8ad9 100644 --- a/crates/jp_storage/Cargo.toml +++ b/crates/jp_storage/Cargo.toml @@ -19,6 +19,7 @@ jp_id = { workspace = true } jp_tombmap = { workspace = true } ahash = { workspace = true } +camino = { workspace = true, features = ["serde1"] } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true, features = ["preserve_order", "raw_value"] } @@ -28,7 +29,7 @@ toml = { workspace = true, features = ["preserve_order"] } tracing = { workspace = true } [dev-dependencies] -tempfile = { workspace = true } +camino-tempfile = { workspace = true } test-log = { workspace = true } [lints] From 9d424576da6f17c18c2bb02407bbf771b56b2874 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:50:34 +0100 Subject: [PATCH 07/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_storage/src/error.rs | 6 +++--- crates/jp_storage/src/value.rs | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/jp_storage/src/error.rs b/crates/jp_storage/src/error.rs index 4d2987b5..648d0cf8 100644 --- a/crates/jp_storage/src/error.rs +++ b/crates/jp_storage/src/error.rs @@ -1,14 +1,14 @@ -use std::path::PathBuf; +use camino::Utf8PathBuf; pub(crate) type Result = std::result::Result; #[derive(Debug, thiserror::Error)] pub enum Error { #[error("Path is not a directory: {0}")] - NotDir(PathBuf), + NotDir(Utf8PathBuf), #[error("Path is not a symlink: {0}")] - NotSymlink(PathBuf), + NotSymlink(Utf8PathBuf), #[error("Conversation error: {0}")] Conversation(#[from] jp_conversation::Error), diff --git a/crates/jp_storage/src/value.rs b/crates/jp_storage/src/value.rs index b0da77a5..43aa2884 100644 --- a/crates/jp_storage/src/value.rs +++ b/crates/jp_storage/src/value.rs @@ -1,17 +1,17 @@ use std::{ fs, io::{BufReader, BufWriter, Write as _}, - path::Path, }; +use camino::Utf8Path; use serde::{Serialize, de::DeserializeOwned}; use serde_json::Value; use crate::error::Result; pub fn merge_files( - base: impl AsRef, - overlay: impl AsRef, + base: impl AsRef, + overlay: impl AsRef, ) -> Result { let base = base.as_ref(); let overlay = overlay.as_ref(); From f1457e007805163a603e8c6cf442f0674ca90b5d Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 12:56:15 +0100 Subject: [PATCH 08/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_config/Cargo.toml | 3 ++- crates/jp_config/src/util.rs | 7 ++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/jp_config/Cargo.toml b/crates/jp_config/Cargo.toml index bd013a76..0ba75a87 100644 --- a/crates/jp_config/Cargo.toml +++ b/crates/jp_config/Cargo.toml @@ -15,6 +15,7 @@ version.workspace = true [dependencies] jp_id = { workspace = true } +camino = { workspace = true } clean-path = { workspace = true } directories = { workspace = true } duct = { workspace = true } @@ -53,11 +54,11 @@ which = { workspace = true, features = ["real-sys"] } [dev-dependencies] assert_matches = { workspace = true } +camino-tempfile = { workspace = true } indoc = { workspace = true } insta = { workspace = true } pretty_assertions = { workspace = true, features = ["std"] } serial_test = { workspace = true } -tempfile = { workspace = true } test-log = { workspace = true } [lints] diff --git a/crates/jp_config/src/util.rs b/crates/jp_config/src/util.rs index d4b5478b..56a28f9a 100644 --- a/crates/jp_config/src/util.rs +++ b/crates/jp_config/src/util.rs @@ -2,6 +2,7 @@ use std::path::{Path, PathBuf}; +use camino::Utf8Path; use glob::glob; use indexmap::IndexMap; use schematic::{ConfigLoader, MergeError, MergeResult, PartialConfig, TransformResult}; @@ -119,7 +120,7 @@ pub fn load_partial_at_path>(path: P) -> Result>( path: P, - root: Option<&Path>, + root: Option<&Utf8Path>, ) -> Result, Error> { let path: PathBuf = path.into(); @@ -411,9 +412,9 @@ mod tests { use std::fs; use assert_matches::assert_matches; + use camino_tempfile::tempdir; use serde_json::{Value, json}; use serial_test::serial; - use tempfile::tempdir; use test_log::test; use super::*; @@ -425,7 +426,7 @@ mod tests { }; // Helper to write config content to a file, creating parent dirs - fn write_config(path: &Path, content: &str) { + fn write_config(path: &Utf8Path, content: &str) { if let Some(parent) = path.parent() { fs::create_dir_all(parent).unwrap(); } From 8d3dba5323074a1a111cf587d9892feb9458035f Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 13:24:52 +0100 Subject: [PATCH 09/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_cli/src/cmd.rs | 2 +- crates/jp_cli/src/cmd/config.rs | 38 ++++++++------- crates/jp_cli/src/cmd/config/fmt.rs | 14 ++---- crates/jp_cli/src/cmd/config/set.rs | 3 +- crates/jp_cli/src/cmd/init.rs | 1 - .../jp_cli/src/cmd/query/response_handler.rs | 19 +++----- crates/jp_cli/src/error.rs | 6 ++- crates/jp_cli/src/lib.rs | 32 +++++++------ crates/jp_config/src/editor.rs | 17 +++---- crates/jp_config/src/fs.rs | 46 +++++++++++-------- crates/jp_llm/Cargo.toml | 1 + crates/jp_llm/src/tool.rs | 27 +++++------ 12 files changed, 108 insertions(+), 98 deletions(-) diff --git a/crates/jp_cli/src/cmd.rs b/crates/jp_cli/src/cmd.rs index 38064b5e..d1980ea7 100644 --- a/crates/jp_cli/src/cmd.rs +++ b/crates/jp_cli/src/cmd.rs @@ -341,7 +341,7 @@ impl From for Error { .into(), MissingConfigFile(path) => [ ("message", "Missing config file".into()), - ("path", path.display().to_string()), + ("path", path.to_string()), ] .into(), }; diff --git a/crates/jp_cli/src/cmd/config.rs b/crates/jp_cli/src/cmd/config.rs index 91782dbd..898f307b 100644 --- a/crates/jp_cli/src/cmd/config.rs +++ b/crates/jp_cli/src/cmd/config.rs @@ -1,5 +1,4 @@ -use std::ffi::OsStr; - +use camino::{FromPathBufError, Utf8Path, Utf8PathBuf}; use jp_config::fs::{ConfigFile, ConfigLoader, ConfigLoaderError, user_global_config_path}; use super::Output; @@ -70,25 +69,32 @@ impl Target { .map(|p| loader.load(p)) .transpose() } else if self.user_global { - user_global_config_path(std::env::home_dir().as_deref()) - .map(|mut p| { - if p.is_file() - && let Some(stem) = p.file_name().and_then(OsStr::to_str) - && let Some(path) = p.parent() - { - loader.file_stem = stem.to_owned().into(); - p = path.to_path_buf(); - } - - loader.load(p) - }) - .transpose() + user_global_config_path( + std::env::home_dir() + .as_deref() + .and_then(|p| Utf8Path::from_path(p)), + ) + .map(|mut p| { + if p.is_file() + && let Some(stem) = p.file_name() + && let Some(path) = p.parent() + { + loader.file_stem = stem.to_owned().into(); + p = path.to_path_buf(); + } + + loader.load(p) + }) + .transpose() } else if self.cwd { loader.file_stem = ".jp".into(); loader.recurse_up = true; loader.recurse_stop_at = Some(ctx.workspace.root().to_path_buf()); - loader.load(std::env::current_dir()?).map(Some) + let current_dir = Utf8PathBuf::try_from(std::env::current_dir()?) + .map_err(FromPathBufError::into_io_error)?; + + loader.load(current_dir).map(Some) } else { ctx.workspace .storage_path() diff --git a/crates/jp_cli/src/cmd/config/fmt.rs b/crates/jp_cli/src/cmd/config/fmt.rs index 565e5e3d..21e60729 100644 --- a/crates/jp_cli/src/cmd/config/fmt.rs +++ b/crates/jp_cli/src/cmd/config/fmt.rs @@ -105,25 +105,19 @@ impl Fmt { if curr != config.content { return Err(Error::CliConfig(format!( "Configuration file {} is not formatted correctly.", - config.path.display() + config.path, )) .into()); } - Ok(format!( - "Checked configuration file: {}", - config.path.display() - )) + Ok(format!("Checked configuration file: {}", config.path,)) } else if curr != config.content { fs::write(&config.path, config.content)?; - Ok(format!( - "Formatted configuration file: {}", - config.path.display() - )) + Ok(format!("Formatted configuration file: {}", config.path,)) } else { Ok(format!( "Skipped formatted configuration file: {}", - config.path.display() + config.path, )) } } diff --git a/crates/jp_cli/src/cmd/config/set.rs b/crates/jp_cli/src/cmd/config/set.rs index ec03dde2..43a944b9 100644 --- a/crates/jp_cli/src/cmd/config/set.rs +++ b/crates/jp_cli/src/cmd/config/set.rs @@ -77,8 +77,7 @@ impl Set { Ok(format!( "Set configuration value for {} in {}", - self.key, - config.path.display() + self.key, config.path, ) .into()) } diff --git a/crates/jp_cli/src/cmd/init.rs b/crates/jp_cli/src/cmd/init.rs index 97988667..3a187861 100644 --- a/crates/jp_cli/src/cmd/init.rs +++ b/crates/jp_cli/src/cmd/init.rs @@ -21,7 +21,6 @@ pub(crate) struct Init { } impl Init { - #[expect(unused_assignments)] pub(crate) fn run(&self, printer: &Printer) -> Output { let cwd: Utf8PathBuf = std::env::current_dir()? .try_into() diff --git a/crates/jp_cli/src/cmd/query/response_handler.rs b/crates/jp_cli/src/cmd/query/response_handler.rs index bd5d08de..d4d41b62 100644 --- a/crates/jp_cli/src/cmd/query/response_handler.rs +++ b/crates/jp_cli/src/cmd/query/response_handler.rs @@ -1,5 +1,6 @@ use std::{fs, sync::Arc, time::Duration}; +use camino::{FromPathBufError, Utf8PathBuf}; use crossterm::style::{Color, Stylize as _}; use jp_config::style::{LinkStyle, StyleConfig}; use jp_printer::{PrintableExt as _, Printer}; @@ -151,16 +152,13 @@ impl ResponseHandler { match style.code.file_link { LinkStyle::Off => {} LinkStyle::Full => { - links.push(format!("{}see: {}", " ".repeat(*indent), path.display())); + links.push(format!("{}see: {path}", " ".repeat(*indent))); } LinkStyle::Osc8 => { links.push(format!( "{}[{}]", " ".repeat(*indent), - hyperlink( - format!("file://{}", path.display()), - "open in editor".red().to_string() - ) + hyperlink(format!("file://{path}"), "open in editor".red().to_string()) )); } } @@ -168,18 +166,14 @@ impl ResponseHandler { match style.code.copy_link { LinkStyle::Off => {} LinkStyle::Full => { - links.push(format!( - "{}copy: copy://{}", - " ".repeat(*indent), - path.display() - )); + links.push(format!("{}copy: copy://{path}", " ".repeat(*indent),)); } LinkStyle::Osc8 => { links.push(format!( "{}[{}]", " ".repeat(*indent), hyperlink( - format!("copy://{}", path.display()), + format!("copy://{path}"), "copy to clipboard".red().to_string() ) )); @@ -258,7 +252,7 @@ impl ResponseHandler { } } - fn persist_code_block(&self) -> Result { + fn persist_code_block(&self) -> Result { let code = self.code_buffer.1.clone(); let language = self.code_buffer.0.as_deref().unwrap_or("txt"); let ext = match language { @@ -276,6 +270,7 @@ impl ResponseHandler { .unwrap_or_default() .subsec_millis(); let path = std::env::temp_dir().join(format!("code_{millis}.{ext}")); + let path = Utf8PathBuf::try_from(path).map_err(FromPathBufError::into_io_error)?; fs::write(&path, code.join("\n"))?; diff --git a/crates/jp_cli/src/error.rs b/crates/jp_cli/src/error.rs index 6044829a..69f500ee 100644 --- a/crates/jp_cli/src/error.rs +++ b/crates/jp_cli/src/error.rs @@ -1,4 +1,6 @@ -use std::{io, path::PathBuf}; +use std::io; + +use camino::Utf8PathBuf; use crate::cmd; @@ -21,7 +23,7 @@ pub(crate) enum Error { /// Missing config file. #[error("Config file not found: {0}")] - MissingConfigFile(PathBuf), + MissingConfigFile(Utf8PathBuf), #[error("CLI Config error: {0}")] CliConfig(String), diff --git a/crates/jp_cli/src/lib.rs b/crates/jp_cli/src/lib.rs index 9cc046ee..a996b889 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -10,7 +10,6 @@ use std::{ fmt, io::{IsTerminal as _, stdout}, num::{NonZeroU8, NonZeroUsize}, - path::PathBuf, process::ExitCode, str::FromStr, sync::atomic::{AtomicUsize, Ordering}, @@ -168,7 +167,7 @@ struct Globals { #[derive(Debug, Clone)] pub(crate) enum KeyValueOrPath { KeyValue(KvAssignment), - Path(PathBuf), + Path(Utf8PathBuf), } impl FromStr for KeyValueOrPath { @@ -177,12 +176,12 @@ impl FromStr for KeyValueOrPath { fn from_str(s: &str) -> Result { // String prefixed with `@` is always a path. if let Some(s) = s.strip_prefix(PATH_STRING_PREFIX) { - return Ok(Self::Path(PathBuf::from(s.trim()))); + return Ok(Self::Path(Utf8PathBuf::from(s.trim()))); } // String without `=` is always a path. if !s.contains('=') { - return Ok(Self::Path(PathBuf::from(s.trim()))); + return Ok(Self::Path(Utf8PathBuf::from(s.trim()))); } // Anything else is parsed as a key-value pair. @@ -200,7 +199,7 @@ impl FromStr for WorkspaceIdOrPath { type Err = Error; fn from_str(s: &str) -> Result { - if PathBuf::from(s).exists() { + if Utf8PathBuf::from(s).exists() { return Ok(Self::Path(Utf8PathBuf::from(s))); } @@ -457,18 +456,24 @@ fn load_cli_cfg_args( // We do this on every iteration of `overrides`, to allow // additional load paths to be added using `--cfg`. let config_load_paths = workspace.iter().flat_map(|w| { - partial - .config_load_paths - .iter() - .flatten() - .map(|p| p.to_path(w.root())) + partial.config_load_paths.iter().flatten().filter_map(|p| { + Utf8PathBuf::try_from(p.to_path(w.root())) + .inspect_err(|e| { + tracing::error!( + path = p.to_string(), + error = e.to_string(), + "Not a valid UTF-8 path" + ) + }) + .ok() + }) }); let mut found = false; for load_path in config_load_paths { debug!( - path = %path.display(), - load_path = %load_path.display(), + path = path.as_str(), + load_path = load_path.as_str(), "Trying to load partial from config load path" ); @@ -501,7 +506,8 @@ fn load_partial_configs_from_files( let mut partials = vec![]; // Load `$XDG_CONFIG_HOME/jp/config.{toml,json,yaml}`. - if let Some(user_global_config) = user_global_config_path(std::env::home_dir().as_deref()) + let home = std::env::home_dir().and_then(|p| Utf8PathBuf::from_path_buf(p).ok()); + if let Some(user_global_config) = user_global_config_path(home.as_deref()) .and_then(|p| load_partial_at_path(p.join("config.toml")).transpose()) .transpose()? { diff --git a/crates/jp_config/src/editor.rs b/crates/jp_config/src/editor.rs index fd9ad4e2..2cbe18de 100644 --- a/crates/jp_config/src/editor.rs +++ b/crates/jp_config/src/editor.rs @@ -1,7 +1,8 @@ //! Editor configuration for Jean-Pierre. -use std::{env, path::PathBuf}; +use std::env; +use camino::Utf8PathBuf; use duct::Expression; use schematic::Config; @@ -101,13 +102,13 @@ impl EditorConfig { /// Return the path to the editor, if any. #[must_use] - pub fn path(&self) -> Option { - self.cmd.as_ref().map(PathBuf::from).or_else(|| { + pub fn path(&self) -> Option { + self.cmd.as_ref().map(Utf8PathBuf::from).or_else(|| { self.envs.iter().find_map(|v| { env::var(v).ok().and_then(|s| { s.split_ascii_whitespace() .next() - .and_then(|c| which::which(c).ok()) + .and_then(|c| which::which(c).ok().and_then(|p| p.try_into().ok())) }) }) }) @@ -178,13 +179,13 @@ mod tests { envs: vec![], }; - assert_eq!(p.path(), Some(PathBuf::from("vim"))); + assert_eq!(p.path(), Some(Utf8PathBuf::from("vim"))); p.cmd = Some("subl -w".into()); - assert_eq!(p.path(), Some(PathBuf::from("subl -w"))); + assert_eq!(p.path(), Some(Utf8PathBuf::from("subl -w"))); p.cmd = Some("/usr/bin/vim".into()); - assert_eq!(p.path(), Some(PathBuf::from("/usr/bin/vim"))); + assert_eq!(p.path(), Some(Utf8PathBuf::from("/usr/bin/vim"))); p.cmd = None; p.envs = vec![]; @@ -192,7 +193,7 @@ mod tests { let _env = EnvVarGuard::set("JP_EDITOR1", "vi"); p.envs = vec!["JP_EDITOR1".into()]; - assert!(p.path().unwrap().to_string_lossy().ends_with("/bin/vi")); + assert!(p.path().unwrap().to_string().ends_with("/bin/vi")); let _env = EnvVarGuard::set("JP_EDITOR2", "doesnotexist"); p.envs = vec!["JP_EDITOR2".into()]; diff --git a/crates/jp_config/src/fs.rs b/crates/jp_config/src/fs.rs index bb3fd70e..b507a383 100644 --- a/crates/jp_config/src/fs.rs +++ b/crates/jp_config/src/fs.rs @@ -1,12 +1,9 @@ //! Configuration file loader. -use std::{ - borrow::Cow, - env, - path::{Path, PathBuf}, -}; +use std::{borrow::Cow, env}; -use clean_path::Clean as _; +use camino::{Utf8Path, Utf8PathBuf}; +use clean_path::clean; use directories::ProjectDirs; use serde::{Deserialize, Serialize}; use tracing::debug; @@ -29,14 +26,14 @@ pub enum ConfigLoaderError { #[error("provided path is not a directory")] PathIsNotADirectory { /// The path which is not a directory. - got: PathBuf, + got: Utf8PathBuf, }, /// Configuration file not found. #[error("config file not found")] NotFound { /// The path to the configuration file. - path: PathBuf, + path: Utf8PathBuf, /// The file stem which was searched for. stem: String, @@ -65,7 +62,7 @@ pub struct ConfigLoader { /// The final path to search for a configuration file, if `recurse_up` is /// enabled. - pub recurse_stop_at: Option, + pub recurse_stop_at: Option, /// Whether to create a new configuration file if none is found. /// @@ -90,7 +87,7 @@ impl Default for ConfigLoader { #[derive(Debug)] pub struct ConfigFile { /// The path to the file. - pub path: PathBuf, + pub path: Utf8PathBuf, /// The format of the file. pub format: Format, @@ -211,7 +208,7 @@ impl ConfigLoader { /// /// Returns an error if the configuration file could not be found, or if the /// file could not be loaded. - pub fn load>(&self, directory: P) -> Result { + pub fn load>(&self, directory: P) -> Result { let directory = directory.as_ref(); // Directory must exist. @@ -288,25 +285,34 @@ impl ConfigLoader { /// Get the path to user the global config directory, if it exists. #[must_use] -pub fn user_global_config_path(home: Option<&Path>) -> Option { +pub fn user_global_config_path(home: Option<&Utf8Path>) -> Option { env::var(GLOBAL_CONFIG_ENV_VAR) .ok() - .and_then(|path| expand_tilde(path, home.and_then(Path::to_str))) - .map(|path| path.clean()) - .inspect(|path| debug!(path = %path.display(), "Custom global configuration file path configured.")) - .or_else(|| ProjectDirs::from("", "", APPLICATION).map(|p| p.config_dir().to_path_buf())) + .and_then(|path| expand_tilde(path, home)) + .and_then(|path| Utf8PathBuf::from_path_buf(clean(path)).ok()) + .inspect(|path| { + debug!( + path = path.as_str(), + "Custom global configuration file path configured." + ); + }) + .or_else(|| { + ProjectDirs::from("", "", APPLICATION) + .map(|p| p.config_dir().to_path_buf()) + .and_then(|path| Utf8PathBuf::from_path_buf(clean(path)).ok()) + }) } /// Expand tilde in path to home directory /// /// If no tilde is found, returns `Some` with the original path. If a tilde is /// found, but no home directory is set, returns `None`. -pub fn expand_tilde>(path: impl AsRef, home: Option) -> Option { +pub fn expand_tilde>(path: impl AsRef, home: Option) -> Option { if path.as_ref().starts_with('~') { - return home.map(|home| PathBuf::from(path.as_ref().replacen('~', home.as_ref(), 1))); + return home.map(|home| Utf8PathBuf::from(path.as_ref().replacen('~', home.as_ref(), 1))); } - Some(PathBuf::from(path.as_ref())) + Some(Utf8PathBuf::from(path.as_ref())) } /// Load a partial configuration, with optional fallback. @@ -370,7 +376,7 @@ mod tests { for (name, case) in cases { assert_eq!( expand_tilde(case.path, case.home), - case.expected.map(PathBuf::from), + case.expected.map(Utf8PathBuf::from), "Failed test case: {name}" ); } diff --git a/crates/jp_llm/Cargo.toml b/crates/jp_llm/Cargo.toml index 6784a1f0..ce7dc311 100644 --- a/crates/jp_llm/Cargo.toml +++ b/crates/jp_llm/Cargo.toml @@ -24,6 +24,7 @@ jp_tool = { workspace = true } async-anthropic = { workspace = true } async-stream = { workspace = true } async-trait = { workspace = true } +camino = { workspace = true } crossterm = { workspace = true } duct = { workspace = true } duct_sh = { workspace = true } diff --git a/crates/jp_llm/src/tool.rs b/crates/jp_llm/src/tool.rs index 0ad9f4d4..8b98bb16 100644 --- a/crates/jp_llm/src/tool.rs +++ b/crates/jp_llm/src/tool.rs @@ -1,5 +1,6 @@ -use std::{fmt::Write, path::Path, sync::Arc}; +use std::{fmt::Write, sync::Arc}; +use camino::Utf8Path; use crossterm::style::Stylize as _; use indexmap::{IndexMap, IndexSet}; use jp_config::conversation::tool::{ @@ -77,7 +78,7 @@ impl ToolDefinition { name: Option<&str>, cmd: &ToolCommandConfig, arguments: &Map, - root: &Path, + root: &Utf8Path, ) -> Result, ToolError> { let name = name.unwrap_or(&self.name); if arguments.is_empty() { @@ -91,7 +92,7 @@ impl ToolDefinition { }, "context": { "action": Action::FormatArguments, - "root": root.to_string_lossy(), + "root": root.as_str(), }, }); @@ -106,8 +107,8 @@ impl ToolDefinition { pending_questions: &IndexSet, mcp_client: &jp_mcp::Client, config: ToolConfigWithDefaults, - root: &Path, - editor: Option<&Path>, + root: &Utf8Path, + editor: Option<&Utf8Path>, writer: PrinterWriter<'_>, ) -> Result { info!(tool = %self.name, arguments = ?arguments, "Calling tool."); @@ -162,7 +163,7 @@ impl ToolDefinition { answers: &IndexMap, config: &ToolConfigWithDefaults, tool: Option<&str>, - root: &Path, + root: &Utf8Path, ) -> Result { let name = tool.unwrap_or(&self.name); @@ -192,7 +193,7 @@ impl ToolDefinition { }, "context": { "action": Action::Run, - "root": root.to_string_lossy().into_owned(), + "root": root.as_str(), }, }); @@ -295,7 +296,7 @@ impl ToolDefinition { arguments: &mut Value, source: &ToolSource, mcp_client: &jp_mcp::Client, - editor: Option<&Path>, + editor: Option<&Utf8Path>, mut writer: PrinterWriter<'_>, ) -> Result<(), ToolError> { match run_mode { @@ -383,7 +384,7 @@ impl ToolDefinition { reason: Some( open_editor::EditorCallBuilder::new() .with_editor(open_editor::Editor::from_bin_path( - editor.to_path_buf(), + editor.into(), )) .edit_string( "_Provide reasoning for skipping tool execution_", @@ -474,7 +475,7 @@ impl ToolDefinition { *arguments = { if let Some(editor) = editor { open_editor::EditorCallBuilder::new() - .with_editor(open_editor::Editor::from_bin_path(editor.to_path_buf())) + .with_editor(open_editor::Editor::from_bin_path(editor.into())) .edit_string_mut(&mut args) .map_err(|error| ToolError::OpenEditorError { arguments: arguments.clone(), @@ -546,7 +547,7 @@ impl ToolDefinition { &self, mut result: ToolCallResponse, result_mode: ResultMode, - editor: Option<&Path>, + editor: Option<&Utf8Path>, mut writer: PrinterWriter<'_>, ) -> Result { match result_mode { @@ -587,7 +588,7 @@ impl ToolDefinition { if let Some(editor) = editor { let content = open_editor::EditorCallBuilder::new() - .with_editor(open_editor::Editor::from_bin_path(editor.to_path_buf())) + .with_editor(open_editor::Editor::from_bin_path(editor.into())) .edit_string(result.content()) .map_err(|error| ToolError::OpenEditorError { arguments: Value::Null, @@ -611,7 +612,7 @@ fn run_cmd_with_ctx( name: &str, command: &ToolCommandConfig, ctx: &Value, - root: &Path, + root: &Utf8Path, ) -> Result, ToolError> { let command = { let tmpl = Arc::new(Environment::new()); From eb74d97aa7d729f9c31990ad2b398dbf1732d541 Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 13:30:41 +0100 Subject: [PATCH 10/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- crates/jp_cli/src/lib.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/jp_cli/src/lib.rs b/crates/jp_cli/src/lib.rs index a996b889..22ad6661 100644 --- a/crates/jp_cli/src/lib.rs +++ b/crates/jp_cli/src/lib.rs @@ -463,7 +463,7 @@ fn load_cli_cfg_args( path = p.to_string(), error = e.to_string(), "Not a valid UTF-8 path" - ) + ); }) .ok() }) @@ -723,6 +723,8 @@ pub fn num_threads() -> NonZeroUsize { #[cfg(feature = "dhat")] fn run_dhat() -> dhat::Profiler { + use std::path::PathBuf; + std::process::Command::new(env!("CARGO")) .arg("locate-project") .arg("--workspace") From 37fce97403529b801493d0744a7db99aea4b91cf Mon Sep 17 00:00:00 2001 From: Jean Mertz Date: Thu, 29 Jan 2026 13:37:14 +0100 Subject: [PATCH 11/11] fixup! refactor: switch to `camino` for UTF-8 path handling Signed-off-by: Jean Mertz --- Cargo.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index edda4cfa..dca1f734 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -98,7 +98,6 @@ sha1 = { version = "0.10", default-features = false } sha2 = { version = "0.10", default-features = false } similar = { version = "2", default-features = false } strip-ansi-escapes = { version = "0.2", default-features = false } -tempfile = { version = "3", default-features = false } termimad = { version = "0.34", default-features = false } test-log = { version = "0.2", default-features = false, features = ["trace"] } thiserror = { version = "2", default-features = false }