1#[cfg(test)]
5mod tests;
6
7use std::cell::{Cell, RefCell};
8use std::ffi::OsString;
9use std::io::{ErrorKind, Read, Write};
10use std::panic::{self, AssertUnwindSafe};
11use std::path::{Path, PathBuf};
12use std::rc::{Rc, Weak};
13
14use crabshell_reactive::{ReadSignal, Signal, untrack};
15use crabshell_ui::Cx;
16use inotify::EventMask;
17use serde::Serialize;
18use serde::de::DeserializeOwned;
19
20use crate::Shared;
21use crate::lines::panic_message;
22use crate::watch::{self, Watch};
23
24pub(crate) const MAX_FILE: u64 = 16 * 1024 * 1024;
27
28pub(crate) struct Files(pub(crate) Rc<Shared>);
31
32struct Follow {
39 shared: Rc<Shared>,
40 path: PathBuf,
41 watch: RefCell<Option<Watch>>,
42 changed: Box<dyn Fn()>,
44}
45
46impl Follow {
47 fn new(shared: &Rc<Shared>, path: PathBuf, changed: impl Fn() + 'static) -> Rc<Follow> {
50 let follow = Rc::new(Follow {
51 shared: Rc::clone(shared),
52 path,
53 watch: RefCell::new(None),
54 changed: Box::new(changed),
55 });
56 follow.rewatch();
57 follow
58 }
59
60 fn rewatch(self: &Rc<Self>) {
63 self.watch.replace(None);
64 let (Some(dir), Some(name)) = (self.path.parent(), self.path.file_name()) else {
65 return;
66 };
67 let mut dir = if dir.as_os_str().is_empty() {
68 Path::new(".")
69 } else {
70 dir
71 };
72 let mut next: OsString = name.to_owned();
73 let own = dir.is_dir();
74 while !dir.is_dir() {
75 let (Some(above), Some(name)) = (dir.parent(), dir.file_name()) else {
76 return;
77 };
78 next = name.to_owned();
79 dir = if above.as_os_str().is_empty() {
80 Path::new(".")
81 } else {
82 above
83 };
84 }
85 let weak = Rc::downgrade(self);
86 let listener = move |change: &watch::Change| {
87 let Some(follow) = weak.upgrade() else {
88 return;
89 };
90 let moved = EventMask::DELETE_SELF | EventMask::MOVE_SELF;
91 match &change.name {
92 None => {
94 if change.mask.intersects(moved) || change.mask.contains(EventMask::Q_OVERFLOW) {
95 follow.rewatch();
96 }
97 (follow.changed)();
98 }
99 Some(changed) if *changed == next && own => {
102 if !change.mask.contains(EventMask::CREATE) {
103 (follow.changed)();
104 }
105 }
106 Some(changed) if *changed == next => {
108 follow.rewatch();
109 (follow.changed)();
110 }
111 Some(_) => {}
112 }
113 };
114 match watch::watch(&self.shared, dir, listener) {
115 Ok(watch) => {
116 self.watch.replace(Some(watch));
117 }
118 Err(error) => {
119 let path = self.path.display();
120 tracing::info!(%path, %error, "can't watch the file, so changes to it aren't seen");
121 }
122 }
123 }
124}
125
126struct Keep<T>(#[allow(dead_code, reason = "held for its drop")] Rc<T>);
128
129#[derive(Clone)]
132pub struct FileView {
133 inner: Rc<ViewInner>,
134}
135
136#[derive(Clone, Debug, PartialEq, Eq)]
139pub struct FileError {
140 pub kind: FileErrorKind,
142 pub message: String,
145}
146
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
149pub enum FileErrorKind {
150 NotFound,
152 PermissionDenied,
154 NotAFile,
157 Other,
159}
160
161impl FileError {
162 fn new(kind: FileErrorKind, message: &str) -> FileError {
163 let message = message.to_owned();
164 FileError { kind, message }
165 }
166}
167
168impl From<std::io::Error> for FileError {
169 fn from(error: std::io::Error) -> FileError {
170 let kind = match error.kind() {
171 ErrorKind::NotFound => FileErrorKind::NotFound,
172 ErrorKind::PermissionDenied => FileErrorKind::PermissionDenied,
173 ErrorKind::IsADirectory => FileErrorKind::NotAFile,
174 _ => FileErrorKind::Other,
175 };
176 FileError::new(kind, &error.to_string())
177 }
178}
179
180struct ViewInner {
181 path: PathBuf,
182 follow: RefCell<Option<Rc<Follow>>>,
183 text: Signal<Option<String>>,
185 error: Signal<Option<FileError>>,
186}
187
188pub(crate) fn file_view(cx: &Cx, path: PathBuf) -> FileView {
190 let inner = Rc::new(ViewInner {
191 text: cx.signal(None),
192 error: cx.signal(None),
193 follow: RefCell::new(None),
194 path,
195 });
196 inner.reread();
197 if let Some(files) = cx.service::<Files>() {
198 let weak = Rc::downgrade(&inner);
199 let follow = Follow::new(&files.0, inner.path.clone(), move || {
200 if let Some(inner) = Weak::upgrade(&weak) {
201 inner.reread();
202 }
203 });
204 inner.follow.replace(Some(follow));
205 }
206 cx.owner().signal(Keep(Rc::clone(&inner)));
208 FileView { inner }
209}
210
211impl FileView {
212 pub fn text(&self) -> ReadSignal<Option<String>> {
216 self.inner.text.read_only()
217 }
218
219 pub fn error(&self) -> ReadSignal<Option<FileError>> {
223 self.inner.error.read_only()
224 }
225
226 pub fn write(&self, text: impl AsRef<str>) {
232 let text = text.as_ref();
233 match write_whole(&self.inner.path, text.as_bytes()) {
234 Ok(()) => {
235 set(self.inner.text, Some(text.to_owned()));
236 set(self.inner.error, None);
237 }
238 Err(error) => {
239 let path = self.inner.path.display();
240 tracing::warn!(%path, %error, "couldn't write the file");
241 }
242 }
243 }
244}
245
246impl ViewInner {
247 fn reread(&self) {
249 let (text, error) = match read_text(&self.path) {
250 Ok(text) => (Some(text), None),
251 Err(error) => {
252 if untrack(|| self.error.try_with(|old| old.as_ref() != Some(&error))) == Some(true) {
254 let path = self.path.display();
255 tracing::info!(%path, error = error.message, "can't read the file");
256 }
257 (None, Some(error))
258 }
259 };
260 set(self.text, text);
261 set(self.error, error);
262 }
263}
264
265pub(crate) fn json_file<T>(cx: &Cx, path: PathBuf, default: T) -> Signal<T>
267where
268 T: Serialize + DeserializeOwned + PartialEq + 'static,
269{
270 let value = cx.signal(default);
271 let file = Rc::new(JsonInner {
272 path,
273 value,
274 on_disk: RefCell::new(None),
276 logged: RefCell::new(None),
277 follow: RefCell::new(None),
278 });
279 file.reread();
280 if let Some(files) = cx.service::<Files>() {
281 let weak = Rc::downgrade(&file);
282 let follow = Follow::new(&files.0, file.path.clone(), move || {
283 if let Some(file) = Weak::upgrade(&weak) {
284 file.reread();
285 }
286 });
287 file.follow.replace(Some(follow));
288 }
289 let start = untrack(|| value.try_with(|value| file.to_json(value))).flatten();
292 let writer = Rc::clone(&file);
293 let first = Cell::new(true);
294 cx.effect(move || {
295 let text = value.with(|value| writer.to_json(value));
296 let unset = first.replace(false) && start.is_some() && text == start;
297 if let (Some(text), false) = (text, unset) {
298 untrack(|| writer.write(text));
299 }
300 })
301 .named(format!("cx.json_file {}", file.path.display()));
302 value
303}
304
305struct JsonInner<T: 'static> {
306 path: PathBuf,
307 value: Signal<T>,
308 on_disk: RefCell<Option<String>>,
309 logged: RefCell<Option<String>>,
311 follow: RefCell<Option<Rc<Follow>>>,
312}
313
314impl<T> JsonInner<T>
315where
316 T: Serialize + DeserializeOwned + PartialEq + 'static,
317{
318 fn reread(&self) {
323 let text = match read_text(&self.path) {
324 Ok(text) => text,
325 Err(error) => {
326 self.log_once(format!("can't read the file: {}", error.message));
327 return;
328 }
329 };
330 let applied = panic::catch_unwind(AssertUnwindSafe(|| {
331 let read = serde_json::from_str::<T>(&text)?;
332 let normal = serde_json::to_string_pretty(&read).unwrap_or_else(|_| text.clone());
333 self.on_disk.replace(Some(normal));
334 set(self.value, read);
335 serde_json::Result::Ok(())
336 }));
337 match applied {
338 Ok(Ok(())) => {
339 self.logged.replace(None);
340 }
341 Ok(Err(error)) => self.log_once(format!("the file isn't the JSON expected: {error}")),
342 Err(payload) => {
343 let message = panic_message(payload.as_ref());
344 self.log_once(format!("reading it panicked: {message}"));
345 }
346 }
347 }
348
349 fn to_json(&self, value: &T) -> Option<String> {
352 let path = self.path.display();
353 match panic::catch_unwind(AssertUnwindSafe(|| serde_json::to_string_pretty(value))) {
354 Ok(Ok(text)) => Some(text),
355 Ok(Err(error)) => {
356 tracing::warn!(%path, %error, "couldn't turn the value into JSON");
357 None
358 }
359 Err(payload) => {
360 let message = panic_message(payload.as_ref());
361 tracing::error!("cx.json_file {path}: turning the value into JSON panicked: {message}");
362 None
363 }
364 }
365 }
366
367 fn write(&self, text: String) {
368 if self.on_disk.borrow().as_deref() == Some(text.as_str()) {
369 return;
370 }
371 match write_whole(&self.path, format!("{text}\n").as_bytes()) {
372 Ok(()) => {
373 self.on_disk.replace(Some(text));
374 }
375 Err(error) => {
376 let path = self.path.display();
377 tracing::warn!(%path, %error, "couldn't write the file");
378 }
379 }
380 }
381
382 fn log_once(&self, problem: String) {
383 if self.logged.borrow().as_deref() != Some(problem.as_str()) {
384 let path = self.path.display();
385 tracing::info!(%path, problem, "the JSON file keeps its value");
386 self.logged.replace(Some(problem));
387 }
388 }
389}
390
391fn read_text(path: &Path) -> Result<String, FileError> {
393 if !std::fs::metadata(path)?.is_file() {
395 return Err(FileError::new(FileErrorKind::NotAFile, "it isn't a file"));
396 }
397 let file = open_file(path)?;
398 let mut bytes = Vec::new();
399 file.take(MAX_FILE + 1).read_to_end(&mut bytes)?;
400 if bytes.len() as u64 > MAX_FILE {
401 return Err(FileError::new(FileErrorKind::Other, "it is over 16 MiB"));
402 }
403 String::from_utf8(bytes).map_err(|_| FileError::new(FileErrorKind::Other, "it isn't UTF-8"))
404}
405
406fn open_file(path: &Path) -> Result<std::fs::File, FileError> {
409 use rustix::fs::{Mode, OFlags};
410
411 let flags = OFlags::RDONLY | OFlags::NONBLOCK | OFlags::NOCTTY | OFlags::CLOEXEC;
412 let fd = rustix::fs::open(path, flags, Mode::empty()).map_err(std::io::Error::from)?;
413 let file = std::fs::File::from(fd);
414 if !file.metadata()?.is_file() {
415 return Err(FileError::new(FileErrorKind::NotAFile, "it isn't a file"));
416 }
417 rustix::fs::fcntl_setfl(&file, OFlags::empty()).map_err(std::io::Error::from)?;
419 Ok(file)
420}
421
422fn write_whole(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
427 use std::os::unix::fs::OpenOptionsExt;
428
429 if let Some(dir) = path.parent()
430 && !dir.as_os_str().is_empty()
431 && !dir.exists()
432 {
433 std::fs::create_dir_all(dir)?;
434 }
435 let target = match std::fs::canonicalize(path) {
436 Ok(target) => target,
437 Err(error) if error.kind() == ErrorKind::NotFound => path.to_owned(),
438 Err(error) => return Err(error),
439 };
440 let name = target
441 .file_name()
442 .ok_or_else(|| std::io::Error::other("the path names no file"))?;
443 let mut partial_name = std::ffi::OsString::from(".");
444 partial_name.push(name);
445 partial_name.push(".crabshell-partial");
446 let partial = target.with_file_name(partial_name);
447 match std::fs::remove_file(&partial) {
448 Err(error) if error.kind() != ErrorKind::NotFound => return Err(error),
449 _ => {}
450 }
451 let mode = std::fs::metadata(&target).map_or(0o644, |meta| {
452 use std::os::unix::fs::PermissionsExt;
453 meta.permissions().mode() & 0o7777
454 });
455 let mut file = std::fs::OpenOptions::new()
457 .write(true)
458 .create_new(true)
459 .mode(mode)
460 .open(&partial)?;
461 let written = file.write_all(bytes).and_then(|()| file.sync_all());
462 drop(file);
463 if let Err(error) = written.and_then(|()| std::fs::rename(&partial, &target)) {
464 let _ = std::fs::remove_file(&partial);
465 return Err(error);
466 }
467 Ok(())
468}
469
470fn set<T: PartialEq + 'static>(signal: Signal<T>, value: T) {
473 if untrack(|| signal.try_with(|old| *old != value)) == Some(true) {
474 signal.try_set(value);
475 }
476}