Skip to main content

optic_file/
lib.rs

1//! Sanitary file I/O and cached-path resolution for the Optic engine.
2//!
3//! All fallible functions return [`OpticResult`] wrapping [`OpticErrorKind::File`]
4//! errors with descriptive messages.
5//!
6//! # Cache path convention
7//!
8//! Assets are cached alongside the source file in an `optc/` subdirectory:
9//!
10//! ```ignore
11//! assets/tex/foo.png     → assets/tex/optc/foo.otxtr
12//! models/cube.obj        → models/optc/cube.omesh
13//! shaders/main.glsl      → shaders/optc/main.oshdr
14//! ```
15
16use optic_core::{OpticError, OpticErrorKind, OpticResult};
17use std::fs;
18use std::io::ErrorKind;
19use std::path::PathBuf;
20
21/// Extract the file stem (name without extension) from a path.
22///
23/// ```
24/// use optic_file::name;
25/// assert_eq!(name("foo.txt"), Some("foo".into()));
26/// assert_eq!(name("/path/to/bar.txt"), Some("bar".into()));
27/// ```
28pub fn name(path: &str) -> Option<String> {
29    let path = PathBuf::from(path);
30    path.file_stem()
31        .map(|n| n.to_string_lossy().to_string())
32}
33
34/// Extract the file extension from a path.
35///
36/// ```
37/// use optic_file::extension;
38/// assert_eq!(extension("foo.txt"), Some("txt".into()));
39/// assert_eq!(extension("Makefile"), None);
40/// ```
41pub fn extension(path: &str) -> Option<String> {
42    let path = PathBuf::from(path);
43    path.extension()
44        .map(|n| n.to_string_lossy().to_string())
45}
46
47/// Check whether a file or directory exists at the given path.
48pub fn exists(path: &str) -> bool {
49    PathBuf::from(path).exists()
50}
51
52/// Read a file as raw bytes.
53///
54/// # Errors
55///
56/// Returns [`OpticErrorKind::File`] if the file is missing, unreadable, or
57/// permission is denied.
58pub fn read_bytes(path: &str) -> OpticResult<Vec<u8>> {
59    match fs::read(path) {
60        Ok(data) => Ok(data),
61        Err(e) => {
62            let kind = match e.kind() {
63                ErrorKind::NotFound | ErrorKind::InvalidInput => "file not found or invalid",
64                ErrorKind::PermissionDenied => "permission denied",
65                _ => "unknown file error",
66            };
67            Err(OpticError::new(
68                OpticErrorKind::File,
69                &format!("{kind}: {path}"),
70            ))
71        }
72    }
73}
74
75/// Read a file as a UTF-8 string.
76///
77/// # Errors
78///
79/// Returns [`OpticErrorKind::File`] if the file does not exist, is not
80/// valid UTF-8, or permission is denied.
81pub fn read_string(path: &str) -> OpticResult<String> {
82    match fs::read_to_string(path) {
83        Ok(data) => Ok(data),
84        Err(e) => {
85            let kind = match e.kind() {
86                ErrorKind::NotFound | ErrorKind::InvalidInput => "file not found or invalid",
87                ErrorKind::PermissionDenied => "permission denied",
88                _ => "unknown file error",
89            };
90            Err(OpticError::new(
91                OpticErrorKind::File,
92                &format!("{kind}: {path}"),
93            ))
94        }
95    }
96}
97
98/// Write raw bytes to a file, creating parent directories if needed.
99///
100/// # Errors
101///
102/// Returns [`OpticErrorKind::File`] if the directory cannot be created or
103/// the file cannot be written.
104pub fn write_bytes(path: &str, data: &[u8]) -> OpticResult<()> {
105    let pathbuf = PathBuf::from(path);
106    if let Some(parent) = pathbuf.parent() {
107        if !parent.exists() {
108            fs::create_dir_all(parent).map_err(|e| {
109                OpticError::new(
110                    OpticErrorKind::File,
111                    &format!("could not create directory {}: {e}", parent.display()),
112                )
113            })?;
114        }
115    }
116    fs::write(path, data).map_err(|e| {
117        OpticError::new(
118            OpticErrorKind::File,
119            &format!("could not write {path}: {e}"),
120        )
121    })
122}
123
124/// Write a UTF-8 string to a file, creating parent directories if needed.
125///
126/// Equivalent to [`write_bytes`] with `data.as_bytes()`.
127pub fn write_string(path: &str, data: &str) -> OpticResult<()> {
128    write_bytes(path, data.as_bytes())
129}
130
131/// Compute the cache path for a source asset.
132///
133/// The cache file is placed in an `optc/` subdirectory next to the source
134/// file, with the given extension replacing the original:
135///
136/// ```
137/// use optic_file::cached_path;
138///
139/// assert_eq!(cached_path("assets/tex/foo.png", "otxtr"),
140///            "assets/tex/optc/foo.otxtr");
141/// assert_eq!(cached_path("foo.png", "omesh"),
142///            "optc/foo.omesh");
143/// ```
144pub fn cached_path(source: &str, ext: &str) -> String {
145    let pb = PathBuf::from(source);
146    let parent = pb.parent().and_then(|p| {
147        let s = p.to_string_lossy().to_string();
148        if s.is_empty() || s == "." { None } else { Some(s) }
149    });
150    let stem = pb.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default();
151    match parent {
152        Some(dir) => format!("{dir}/optc/{stem}.{ext}"),
153        None => format!("optc/{stem}.{ext}"),
154    }
155}
156
157/// Create a directory and all parent directories (like `mkdir -p`).
158pub fn create_dir(path: &str) -> OpticResult<()> {
159    fs::create_dir_all(path).map_err(|e| {
160        OpticError::new(
161            OpticErrorKind::File,
162            &format!("could not create directory {path}: {e}"),
163        )
164    })
165}