Skip to main content

kcode_rust_libs/
repository.rs

1use std::collections::HashSet;
2use std::fmt;
3use std::fs;
4use std::io;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7
8use crate::files::{
9    absolute_lexical, load_files, paths_overlap, validate_rust_lib_name, validate_write_target,
10    write_file,
11};
12use crate::sandbox;
13use crate::{CheckResult, Error, Result, RustLibDocs, RustLibFile};
14
15const DOCUMENTATION_FILE: &str = "Documentation.md";
16const MANIFEST_FILE: &str = "Cargo.toml";
17const LEGACY_VERSION_FILE: &str = "Version.txt";
18
19#[derive(Clone)]
20struct RegistryToken(Arc<str>);
21
22impl RegistryToken {
23    fn new(value: impl Into<String>) -> Result<Self> {
24        let value = value.into();
25        let value = value.trim();
26        if value.is_empty() {
27            return Err(Error::InvalidRegistryToken);
28        }
29        Ok(Self(Arc::from(value)))
30    }
31
32    fn expose(&self) -> &str {
33        self.0.as_ref()
34    }
35}
36
37impl fmt::Debug for RegistryToken {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        f.write_str("RegistryToken([REDACTED])")
40    }
41}
42
43/// Entry point for creating and opening managed Rust libraries.
44#[derive(Clone, Debug)]
45pub struct KcodeRustLibs {
46    rust_libs_root: PathBuf,
47    work_root: PathBuf,
48    registry_token: RegistryToken,
49}
50
51impl KcodeRustLibs {
52    /// Use `rust_libs_root` as the persistent library directory and retain the supplied crates.io
53    /// registry token in memory for publication.
54    pub fn new(
55        rust_libs_root: impl Into<PathBuf>,
56        crates_io_registry_token: impl Into<String>,
57    ) -> Result<Self> {
58        let registry_token = RegistryToken::new(crates_io_registry_token)?;
59        let rust_libs_lexical = absolute_lexical(&rust_libs_root.into())?;
60        let work_lexical = absolute_lexical(
61            &std::env::temp_dir().join(format!("kcode-rust-libs-work-{}", std::process::id())),
62        )?;
63        if paths_overlap(&rust_libs_lexical, &work_lexical) {
64            return Err(Error::RootsOverlap {
65                rust_libs_root: rust_libs_lexical,
66                work_root: work_lexical,
67            });
68        }
69
70        create_root(&rust_libs_lexical, "create Rust libraries root")?;
71        create_root(&work_lexical, "create work root")?;
72
73        let rust_libs_root = fs::canonicalize(&rust_libs_lexical).map_err(|source| {
74            Error::io(
75                "canonicalize Rust libraries root",
76                &rust_libs_lexical,
77                source,
78            )
79        })?;
80        let work_root = fs::canonicalize(&work_lexical)
81            .map_err(|source| Error::io("canonicalize work root", &work_lexical, source))?;
82
83        if paths_overlap(&rust_libs_root, &work_root) {
84            return Err(Error::RootsOverlap {
85                rust_libs_root,
86                work_root,
87            });
88        }
89
90        Ok(Self {
91            rust_libs_root,
92            work_root,
93            registry_token,
94        })
95    }
96
97    /// Create a minimal managed Rust library and return it already loaded.
98    pub fn create_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
99        validate_rust_lib_name(name)?;
100        let root = self.rust_libs_root.join(name);
101
102        match fs::create_dir(&root) {
103            Ok(()) => {}
104            Err(source) if source.kind() == io::ErrorKind::AlreadyExists => {
105                return Err(Error::RustLibAlreadyExists(name.to_owned()));
106            }
107            Err(source) => return Err(Error::io("create Rust library", root, source)),
108        }
109
110        let created = (|| {
111            let source_directory = root.join("src");
112            fs::create_dir(&source_directory).map_err(|source| {
113                Error::io(
114                    "create Rust library source directory",
115                    &source_directory,
116                    source,
117                )
118            })?;
119
120            let manifest = format!(
121                "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\nedition = \"2024\"\n\n[dependencies]\n"
122            );
123            write_new_file(&root.join(MANIFEST_FILE), &manifest, "write Cargo manifest")?;
124            write_new_file(
125                &root.join(DOCUMENTATION_FILE),
126                "",
127                "write Rust library documentation",
128            )?;
129            write_new_file(&source_directory.join("lib.rs"), "", "write library source")?;
130            Ok(())
131        })();
132
133        if let Err(error) = created {
134            let _ = fs::remove_dir_all(&root);
135            return Err(error);
136        }
137
138        self.open_validated(name, root)
139    }
140
141    /// Open an existing managed Rust library and recursively load all of its current files.
142    pub fn open_rust_lib(&self, name: &str) -> Result<OpenedRustLib> {
143        validate_rust_lib_name(name)?;
144        let root = self.rust_libs_root.join(name);
145        let metadata = match fs::symlink_metadata(&root) {
146            Ok(metadata) => metadata,
147            Err(source) if source.kind() == io::ErrorKind::NotFound => {
148                return Err(Error::RustLibNotFound(name.to_owned()));
149            }
150            Err(source) => return Err(Error::io("inspect Rust library", root, source)),
151        };
152
153        if metadata.file_type().is_symlink() {
154            return Err(Error::SymlinkNotAllowed(root));
155        }
156        if !metadata.is_dir() {
157            return Err(Error::RustLibIsNotDirectory(name.to_owned()));
158        }
159
160        self.open_validated(name, root)
161    }
162
163    fn open_validated(&self, name: &str, root: PathBuf) -> Result<OpenedRustLib> {
164        let mut files = load_files(&root)?;
165        let mut docs = validate_rust_lib_metadata(&files)?;
166
167        if remove_legacy_version_file(&root, &files)? {
168            files = load_files(&root)?;
169            docs = validate_rust_lib_metadata(&files)?;
170        }
171
172        Ok(OpenedRustLib {
173            name: name.to_owned(),
174            root,
175            work_root: self.work_root.clone(),
176            registry_token: self.registry_token.clone(),
177            files,
178            docs,
179        })
180    }
181}
182
183fn create_root(path: &Path, action: &'static str) -> Result<()> {
184    fs::create_dir_all(path).map_err(|source| Error::io(action, path, source))?;
185    let metadata = fs::metadata(path).map_err(|source| Error::io("inspect root", path, source))?;
186    if !metadata.is_dir() {
187        return Err(Error::io(
188            "use root as a directory",
189            path,
190            io::Error::new(io::ErrorKind::NotADirectory, "path is not a directory"),
191        ));
192    }
193    Ok(())
194}
195
196fn write_new_file(path: &Path, contents: &str, action: &'static str) -> Result<()> {
197    fs::write(path, contents).map_err(|source| Error::io(action, path, source))
198}
199
200fn remove_legacy_version_file(root: &Path, files: &[RustLibFile]) -> Result<bool> {
201    if !files
202        .iter()
203        .any(|file| file.path.as_str() == LEGACY_VERSION_FILE)
204    {
205        return Ok(false);
206    }
207
208    let path = root.join(LEGACY_VERSION_FILE);
209    let metadata = fs::symlink_metadata(&path)
210        .map_err(|source| Error::io("inspect legacy version file", &path, source))?;
211    if metadata.file_type().is_symlink() {
212        return Err(Error::SymlinkNotAllowed(path));
213    }
214    if !metadata.is_file() {
215        return Err(Error::UnsupportedFileType(path));
216    }
217
218    fs::remove_file(&path)
219        .map_err(|source| Error::io("remove legacy version file", &path, source))?;
220    Ok(true)
221}
222
223/// A managed Rust library whose complete file set was loaded when it was opened.
224#[derive(Debug)]
225pub struct OpenedRustLib {
226    name: String,
227    root: PathBuf,
228    work_root: PathBuf,
229    registry_token: RegistryToken,
230    files: Vec<RustLibFile>,
231    docs: RustLibDocs,
232}
233
234impl OpenedRustLib {
235    pub fn name(&self) -> &str {
236        &self.name
237    }
238
239    pub fn files(&self) -> &[RustLibFile] {
240        &self.files
241    }
242
243    /// Return only the canonical manifest version and agent-facing documentation text.
244    pub fn docs(&self) -> &RustLibDocs {
245        &self.docs
246    }
247
248    /// Create or completely overwrite every file in the supplied batch.
249    pub fn write(&mut self, files: &[RustLibFile]) -> Result<()> {
250        let mut unique_paths = HashSet::with_capacity(files.len());
251        for file in files {
252            if !unique_paths.insert(file.path.clone()) {
253                return Err(Error::DuplicateWritePath(file.path.to_string()));
254            }
255            validate_write_target(&self.root, &file.path)?;
256        }
257
258        let current_files = load_files(&self.root)?;
259        validate_projected_metadata(&current_files, files)?;
260
261        for file in files {
262            write_file(&self.root, file)?;
263        }
264
265        let reloaded = load_files(&self.root)?;
266        let docs = validate_rust_lib_metadata(&reloaded)?;
267        self.files = reloaded;
268        self.docs = docs;
269        Ok(())
270    }
271
272    /// Run the complete Rust quality and test pipeline in disposable Podman containers.
273    pub fn check(&self) -> Result<CheckResult> {
274        sandbox::check(&self.root, &self.work_root)
275    }
276
277    /// Check the Rust library and publish its root Cargo package to crates.io using the token
278    /// supplied when `KcodeRustLibs` was initialized.
279    pub fn publish(&self) -> Result<()> {
280        let check = self.check()?;
281        if !check.passed() {
282            return Err(Error::CheckFailed(check));
283        }
284
285        sandbox::publish(&self.root, &self.work_root, self.registry_token.expose())
286    }
287}
288
289fn validate_projected_metadata(
290    current: &[RustLibFile],
291    writes: &[RustLibFile],
292) -> Result<RustLibDocs> {
293    let projected = |path: &str| {
294        writes
295            .iter()
296            .find(|file| file.path.as_str() == path)
297            .or_else(|| current.iter().find(|file| file.path.as_str() == path))
298            .map(|file| file.contents.as_str())
299    };
300    validate_metadata_text(projected(DOCUMENTATION_FILE), projected(MANIFEST_FILE))
301}
302
303fn validate_rust_lib_metadata(files: &[RustLibFile]) -> Result<RustLibDocs> {
304    let contents = |path: &str| {
305        files
306            .iter()
307            .find(|file| file.path.as_str() == path)
308            .map(|file| file.contents.as_str())
309    };
310    validate_metadata_text(contents(DOCUMENTATION_FILE), contents(MANIFEST_FILE))
311}
312
313fn validate_metadata_text(
314    documentation: Option<&str>,
315    manifest: Option<&str>,
316) -> Result<RustLibDocs> {
317    let documentation = documentation.ok_or(Error::MissingRequiredFile(DOCUMENTATION_FILE))?;
318    let manifest = manifest.ok_or(Error::MissingRequiredFile(MANIFEST_FILE))?;
319    let version = parse_version(&manifest_package_version(manifest)?)?;
320    Ok(RustLibDocs {
321        version,
322        documentation: documentation.to_owned(),
323    })
324}
325
326fn parse_version(value: &str) -> Result<String> {
327    let components = value.split('.').collect::<Vec<_>>();
328    let valid_component = |component: &str| {
329        !component.is_empty()
330            && component.bytes().all(|byte| byte.is_ascii_digit())
331            && (component == "0" || !component.starts_with('0'))
332    };
333
334    if components.len() != 3 || !components.into_iter().all(valid_component) {
335        return Err(Error::InvalidVersion(value.to_owned()));
336    }
337
338    Ok(value.to_owned())
339}
340
341fn manifest_package_version(manifest: &str) -> Result<String> {
342    let mut in_package = false;
343    let mut version = None;
344
345    for raw_line in manifest.lines() {
346        let line = strip_toml_comment(raw_line).trim();
347        if line.is_empty() {
348            continue;
349        }
350        if line.starts_with('[') {
351            in_package = line
352                .strip_prefix('[')
353                .and_then(|value| value.strip_suffix(']'))
354                .is_some_and(|value| value.trim() == "package");
355            continue;
356        }
357        if !in_package {
358            continue;
359        }
360
361        let Some((key, value)) = line.split_once('=') else {
362            continue;
363        };
364        if key.trim() != "version" {
365            continue;
366        }
367        if version.is_some() {
368            return Err(Error::InvalidCargoManifest(
369                "[package] declares version more than once".to_owned(),
370            ));
371        }
372
373        let value = value.trim();
374        let unquoted = value
375            .strip_prefix('"')
376            .and_then(|value| value.strip_suffix('"'))
377            .or_else(|| {
378                value
379                    .strip_prefix('\'')
380                    .and_then(|value| value.strip_suffix('\''))
381            })
382            .filter(|value| !value.contains(['"', '\'', '\\']))
383            .ok_or_else(|| {
384                Error::InvalidCargoManifest(
385                    "[package].version must be a single-line literal string".to_owned(),
386                )
387            })?;
388        version = Some(unquoted.to_owned());
389    }
390
391    version.ok_or_else(|| {
392        Error::InvalidCargoManifest(
393            "the root manifest needs a literal version in [package]".to_owned(),
394        )
395    })
396}
397
398fn strip_toml_comment(line: &str) -> &str {
399    let mut quote = None;
400    let mut escaped = false;
401    for (index, character) in line.char_indices() {
402        match quote {
403            Some('"') if escaped => escaped = false,
404            Some('"') if character == '\\' => escaped = true,
405            Some(active) if character == active => quote = None,
406            Some(_) => {}
407            None if character == '"' || character == '\'' => quote = Some(character),
408            None if character == '#' => return &line[..index],
409            None => {}
410        }
411    }
412    line
413}
414
415#[cfg(test)]
416mod tests {
417    use super::{parse_version, validate_rust_lib_metadata};
418    use crate::{Error, RustLibFile, RustLibPath};
419
420    fn file(path: &str, contents: &str) -> RustLibFile {
421        RustLibFile::new(RustLibPath::new(path).unwrap(), contents)
422    }
423
424    #[test]
425    fn accepts_only_canonical_stable_versions() {
426        for value in ["0.1.0", "12.34.56"] {
427            assert!(parse_version(value).is_ok(), "{value:?}");
428        }
429        for value in [
430            "v0.1.0",
431            "01.2.3",
432            "0.1",
433            "0.1.0-beta.1",
434            "0.1.0+build",
435            " 0.1.0 ",
436        ] {
437            assert!(
438                matches!(parse_version(value), Err(Error::InvalidVersion(_))),
439                "{value:?}"
440            );
441        }
442    }
443
444    #[test]
445    fn reads_the_canonical_version_from_the_root_manifest() {
446        let files = vec![
447            file(
448                "Cargo.toml",
449                "[package]\nname = \"demo#one\"\nversion = '2.0.0' # current\n",
450            ),
451            file("Documentation.md", "docs\n"),
452        ];
453        let docs = validate_rust_lib_metadata(&files).unwrap();
454        assert_eq!(docs.version, "2.0.0");
455        assert_eq!(docs.documentation, "docs\n");
456
457        let inherited = vec![
458            file(
459                "Cargo.toml",
460                "[package]\nname = \"demo\"\nversion.workspace = true\n",
461            ),
462            file("Documentation.md", ""),
463        ];
464        assert!(matches!(
465            validate_rust_lib_metadata(&inherited),
466            Err(Error::InvalidCargoManifest(_))
467        ));
468    }
469}