Skip to main content

kcode_dev_tools/
lib.rs

1//! Session-scoped tools for three managed-source backends.
2
3mod backend;
4mod registry;
5
6use std::error::Error as StdError;
7use std::fmt;
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use std::time::Duration;
11
12use http::StatusCode;
13use serde_json::Value;
14use zeroize::Zeroizing;
15
16pub use kcode_rust_bins::{ObjectStore, ObjectStoreError, ObjectStoreResult};
17
18use registry::RegistryState;
19
20pub const CREATE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/create";
21pub const OPEN_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/open";
22pub const DOCS_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/docs";
23pub const WRITE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/write";
24pub const WRITE_FILE_FREEFORM_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/write-file-freeform";
25pub const DELETE_FILE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/delete-file";
26pub const CHECK_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/check";
27pub const PUBLISH_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/publish";
28pub const PREVIEW_WRITE_FILE_RUST_LIB_TOOL: &str = "kcode-rust-libs-v2/internal-preview-write-file";
29
30pub const CREATE_WEB_LIB_TOOL: &str = "kcode-web-libs/create";
31pub const OPEN_WEB_LIB_TOOL: &str = "kcode-web-libs/open";
32pub const DOCS_WEB_LIB_TOOL: &str = "kcode-web-libs/docs";
33pub const WRITE_WEB_LIB_TOOL: &str = "kcode-web-libs/write";
34pub const WRITE_FILE_FREEFORM_WEB_LIB_TOOL: &str = "kcode-web-libs/write-file-freeform";
35pub const DELETE_FILE_WEB_LIB_TOOL: &str = "kcode-web-libs/delete-file";
36pub const CHECK_WEB_LIB_TOOL: &str = "kcode-web-libs/check";
37pub const PUBLISH_WEB_LIB_TOOL: &str = "kcode-web-libs/publish";
38pub const PREVIEW_WRITE_FILE_WEB_LIB_TOOL: &str = "kcode-web-libs/internal-preview-write-file";
39
40pub const CREATE_RUST_BIN_TOOL: &str = "kcode-rust-bins/create";
41pub const OPEN_RUST_BIN_TOOL: &str = "kcode-rust-bins/open";
42pub const DOCS_RUST_BIN_TOOL: &str = "kcode-rust-bins/docs";
43pub const WRITE_RUST_BIN_TOOL: &str = "kcode-rust-bins/write";
44pub const WRITE_FILE_FREEFORM_RUST_BIN_TOOL: &str = "kcode-rust-bins/write-file-freeform";
45pub const DELETE_FILE_RUST_BIN_TOOL: &str = "kcode-rust-bins/delete-file";
46pub const CHECK_RUST_BIN_TOOL: &str = "kcode-rust-bins/check";
47pub const PUBLISH_RUST_BIN_TOOL: &str = "kcode-rust-bins/publish";
48pub const CALL_RUST_BIN_TOOL: &str = "kcode-rust-bins/call";
49pub const PREVIEW_WRITE_FILE_RUST_BIN_TOOL: &str = "kcode-rust-bins/internal-preview-write-file";
50
51pub const RUST_LIB_TOOLS: [&str; 8] = [
52    CREATE_RUST_LIB_TOOL,
53    OPEN_RUST_LIB_TOOL,
54    DOCS_RUST_LIB_TOOL,
55    WRITE_RUST_LIB_TOOL,
56    WRITE_FILE_FREEFORM_RUST_LIB_TOOL,
57    DELETE_FILE_RUST_LIB_TOOL,
58    CHECK_RUST_LIB_TOOL,
59    PUBLISH_RUST_LIB_TOOL,
60];
61
62pub const WEB_LIB_TOOLS: [&str; 8] = [
63    CREATE_WEB_LIB_TOOL,
64    OPEN_WEB_LIB_TOOL,
65    DOCS_WEB_LIB_TOOL,
66    WRITE_WEB_LIB_TOOL,
67    WRITE_FILE_FREEFORM_WEB_LIB_TOOL,
68    DELETE_FILE_WEB_LIB_TOOL,
69    CHECK_WEB_LIB_TOOL,
70    PUBLISH_WEB_LIB_TOOL,
71];
72
73pub const RUST_BIN_TOOLS: [&str; 9] = [
74    CREATE_RUST_BIN_TOOL,
75    OPEN_RUST_BIN_TOOL,
76    DOCS_RUST_BIN_TOOL,
77    WRITE_RUST_BIN_TOOL,
78    WRITE_FILE_FREEFORM_RUST_BIN_TOOL,
79    DELETE_FILE_RUST_BIN_TOOL,
80    CHECK_RUST_BIN_TOOL,
81    PUBLISH_RUST_BIN_TOOL,
82    CALL_RUST_BIN_TOOL,
83];
84
85const SESSION_LEASE: Duration = Duration::from_secs(24 * 60 * 60);
86const MAX_SESSION_ID_BYTES: usize = 128;
87
88pub struct Config {
89    pub rust_libraries_root: PathBuf,
90    pub web_libraries_root: PathBuf,
91    pub web_publications_root: PathBuf,
92    pub rust_binaries_root: PathBuf,
93    pub rust_binary_publications_root: PathBuf,
94    pub crates_io_registry_token: String,
95    pub object_store: Arc<dyn ObjectStore>,
96}
97
98#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
99pub enum ManagedSourceKind {
100    RustLibrary,
101    WebLibrary,
102    RustBinary,
103}
104
105impl ManagedSourceKind {
106    pub fn label(self) -> &'static str {
107        match self {
108            Self::RustLibrary => "Rust library",
109            Self::WebLibrary => "Web library",
110            Self::RustBinary => "Rust binary",
111        }
112    }
113
114    pub fn open_tool(self) -> &'static str {
115        match self {
116            Self::RustLibrary => OPEN_RUST_LIB_TOOL,
117            Self::WebLibrary => OPEN_WEB_LIB_TOOL,
118            Self::RustBinary => OPEN_RUST_BIN_TOOL,
119        }
120    }
121
122    pub(crate) fn code(self, suffix: &'static str) -> &'static str {
123        match (self, suffix) {
124            (Self::RustLibrary, "already_open") => "rust_lib_already_open",
125            (Self::RustLibrary, "not_open") => "rust_lib_not_open",
126            (Self::RustLibrary, "session_ending") => "rust_lib_session_ending",
127            (Self::WebLibrary, "already_open") => "web_lib_already_open",
128            (Self::WebLibrary, "not_open") => "web_lib_not_open",
129            (Self::WebLibrary, "session_ending") => "web_lib_session_ending",
130            (Self::RustBinary, "already_open") => "rust_bin_already_open",
131            (Self::RustBinary, "not_open") => "rust_bin_not_open",
132            (Self::RustBinary, "session_ending") => "rust_bin_session_ending",
133            _ => "managed_source_error",
134        }
135    }
136}
137
138#[derive(Clone, Debug, Eq, PartialEq)]
139pub struct SourceSnapshot {
140    pub kind: ManagedSourceKind,
141    pub name: String,
142    pub text: String,
143}
144
145#[derive(Clone, Debug, Eq, PartialEq)]
146pub struct ToolExecution {
147    pub text: String,
148    pub snapshot: Option<SourceSnapshot>,
149}
150
151#[derive(Debug)]
152pub struct ToolError {
153    pub status: StatusCode,
154    pub code: &'static str,
155    pub message: String,
156}
157
158#[derive(Clone)]
159pub struct Service {
160    pub(crate) inner: Arc<ServiceInner>,
161}
162
163pub(crate) struct ServiceInner {
164    pub(crate) rust_libraries_root: PathBuf,
165    pub(crate) web_libraries_root: PathBuf,
166    pub(crate) web_publications_root: PathBuf,
167    pub(crate) rust_binaries_root: PathBuf,
168    pub(crate) rust_binary_publications_root: PathBuf,
169    pub(crate) registry_token: Zeroizing<String>,
170    pub(crate) object_store: Arc<dyn ObjectStore>,
171    pub(crate) registry: Arc<RegistryState>,
172}
173
174impl Service {
175    pub fn open(config: Config) -> Result<Self, ToolError> {
176        let registry_token = config.crates_io_registry_token.trim();
177        if registry_token.is_empty() {
178            return Err(ToolError::unavailable(
179                "registry_token_unavailable",
180                "The crates.io registry token is empty.",
181            ));
182        }
183
184        let web_libraries_root = checked_root(&config.web_libraries_root, "managed Web libraries")?;
185        let web_publications_root =
186            checked_root(&config.web_publications_root, "Web library publications")?;
187        if web_libraries_root.starts_with(&web_publications_root)
188            || web_publications_root.starts_with(&web_libraries_root)
189        {
190            return Err(ToolError::unprocessable(
191                "dev_tools_invalid_root",
192                "Managed Web source and publication roots must be disjoint.",
193            ));
194        }
195
196        Ok(Self {
197            inner: Arc::new(ServiceInner {
198                rust_libraries_root: checked_root(
199                    &config.rust_libraries_root,
200                    "managed Rust libraries",
201                )?,
202                web_libraries_root,
203                web_publications_root,
204                rust_binaries_root: checked_root(
205                    &config.rust_binaries_root,
206                    "managed Rust binaries",
207                )?,
208                rust_binary_publications_root: checked_root(
209                    &config.rust_binary_publications_root,
210                    "Rust binary publications",
211                )?,
212                registry_token: Zeroizing::new(registry_token.to_owned()),
213                object_store: config.object_store,
214                registry: Arc::new(RegistryState::default()),
215            }),
216        })
217    }
218
219    pub fn web_libraries_root(&self) -> &Path {
220        &self.inner.web_libraries_root
221    }
222
223    pub fn web_publications_root(&self) -> &Path {
224        &self.inner.web_publications_root
225    }
226
227    pub async fn execute(
228        &self,
229        session_id: impl Into<String>,
230        tool_name: impl Into<String>,
231        arguments: Value,
232    ) -> Result<ToolExecution, ToolError> {
233        let session_id = session_id.into();
234        validate_session_id(&session_id)?;
235        let tool_name = tool_name.into();
236        let service = self.clone();
237        tokio::task::spawn_blocking(move || {
238            service.execute_blocking(&session_id, &tool_name, arguments)
239        })
240        .await
241        .map_err(|error| {
242            tracing::error!(error=%error, "managed-source tool worker failed");
243            ToolError::internal("The managed-source tool worker stopped unexpectedly.")
244        })?
245    }
246
247    pub async fn release(&self, session_id: impl Into<String>) -> Result<usize, ToolError> {
248        let session_id = session_id.into();
249        validate_session_id(&session_id)?;
250        let service = self.clone();
251        tokio::task::spawn_blocking(move || service.release_blocking(&session_id))
252            .await
253            .map_err(|error| {
254                tracing::error!(error=%error, "managed-source release worker failed");
255                ToolError::internal("The managed-source release worker stopped unexpectedly.")
256            })?
257    }
258}
259
260impl ToolExecution {
261    pub(crate) fn plain(text: impl Into<String>) -> Self {
262        Self {
263            text: text.into(),
264            snapshot: None,
265        }
266    }
267
268    pub(crate) fn snapshot(snapshot: SourceSnapshot) -> Self {
269        Self {
270            text: snapshot.text.clone(),
271            snapshot: Some(snapshot),
272        }
273    }
274
275    pub(crate) fn with_snapshot(text: impl Into<String>, snapshot: SourceSnapshot) -> Self {
276        Self {
277            text: text.into(),
278            snapshot: Some(snapshot),
279        }
280    }
281}
282
283impl ToolError {
284    pub(crate) fn invalid(message: impl Into<String>) -> Self {
285        Self {
286            status: StatusCode::BAD_REQUEST,
287            code: "invalid_arguments",
288            message: message.into(),
289        }
290    }
291
292    pub(crate) fn not_found(code: &'static str, message: impl Into<String>) -> Self {
293        Self {
294            status: StatusCode::NOT_FOUND,
295            code,
296            message: message.into(),
297        }
298    }
299
300    pub(crate) fn conflict(code: &'static str, message: impl Into<String>) -> Self {
301        Self {
302            status: StatusCode::CONFLICT,
303            code,
304            message: message.into(),
305        }
306    }
307
308    pub(crate) fn unprocessable(code: &'static str, message: impl Into<String>) -> Self {
309        Self {
310            status: StatusCode::UNPROCESSABLE_ENTITY,
311            code,
312            message: message.into(),
313        }
314    }
315
316    pub(crate) fn unavailable(code: &'static str, message: impl Into<String>) -> Self {
317        Self {
318            status: StatusCode::SERVICE_UNAVAILABLE,
319            code,
320            message: message.into(),
321        }
322    }
323
324    pub(crate) fn internal(message: impl Into<String>) -> Self {
325        Self {
326            status: StatusCode::INTERNAL_SERVER_ERROR,
327            code: "dev_tools_internal_error",
328            message: message.into(),
329        }
330    }
331}
332
333impl fmt::Display for ToolError {
334    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
335        write!(formatter, "{}: {}", self.code, self.message)
336    }
337}
338
339impl StdError for ToolError {}
340
341pub fn proposed_write_snapshot(tool_name: &str, arguments: &Value) -> Option<SourceSnapshot> {
342    backend::proposed_write_snapshot(tool_name, arguments)
343}
344
345fn checked_root(path: &Path, label: &str) -> Result<PathBuf, ToolError> {
346    std::fs::create_dir_all(path).map_err(|error| {
347        ToolError::unavailable(
348            "dev_tools_storage_unavailable",
349            format!("Could not create {label} root {}: {error}", path.display()),
350        )
351    })?;
352    let metadata = std::fs::symlink_metadata(path).map_err(|error| {
353        ToolError::unavailable(
354            "dev_tools_storage_unavailable",
355            format!("Could not inspect {label} root {}: {error}", path.display()),
356        )
357    })?;
358    if metadata.file_type().is_symlink() || !metadata.is_dir() {
359        return Err(ToolError::unprocessable(
360            "dev_tools_invalid_root",
361            format!("{label} root {} must be a real directory.", path.display()),
362        ));
363    }
364    std::fs::canonicalize(path).map_err(|error| {
365        ToolError::unavailable(
366            "dev_tools_storage_unavailable",
367            format!(
368                "Could not canonicalize {label} root {}: {error}",
369                path.display()
370            ),
371        )
372    })
373}
374
375fn validate_session_id(session_id: &str) -> Result<(), ToolError> {
376    if session_id.is_empty()
377        || session_id.len() > MAX_SESSION_ID_BYTES
378        || !session_id
379            .bytes()
380            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b':'))
381    {
382        return Err(ToolError::invalid("The hidden tool session ID is invalid."));
383    }
384    Ok(())
385}