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}