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