glory_cli/service/
notify.rs

1use crate::compile::Change;
2use crate::config::Project;
3use crate::ext::anyhow::{anyhow, Result};
4use crate::signal::Interrupt;
5use crate::{
6    ext::{remove_nested, PathBufExt, PathExt},
7    logger::GRAY,
8};
9use camino::Utf8PathBuf;
10use itertools::Itertools;
11use notify::{DebouncedEvent, RecursiveMode, Watcher};
12use std::collections::HashSet;
13use std::path::Path;
14use std::sync::Arc;
15use std::{fmt::Display, time::Duration};
16use tokio::task::JoinHandle;
17
18pub async fn spawn(proj: &Arc<Project>) -> Result<JoinHandle<()>> {
19    let mut set: HashSet<Utf8PathBuf> = HashSet::from_iter(vec![]);
20
21    set.extend(proj.lib.src_paths.clone());
22    set.extend(proj.bin.src_paths.clone());
23    set.insert(proj.js_dir.clone());
24
25    if let Some(file) = &proj.style.file {
26        set.insert(file.source.clone().without_last());
27    }
28
29    if let Some(tailwind) = &proj.style.tailwind {
30        set.insert(tailwind.config_file.clone());
31        set.insert(tailwind.input_file.clone());
32    }
33
34    if let Some(assets) = &proj.assets {
35        set.insert(assets.dir.clone());
36    }
37
38    let paths = remove_nested(set.into_iter().filter(|path| Path::new(path).exists()));
39
40    log::info!("Notify watching paths {}", GRAY.paint(paths.iter().join(", ")));
41    let proj = proj.clone();
42
43    Ok(tokio::spawn(async move { run(&paths, proj).await }))
44}
45
46async fn run(paths: &[Utf8PathBuf], proj: Arc<Project>) {
47    let (sync_tx, sync_rx) = std::sync::mpsc::channel::<DebouncedEvent>();
48
49    let proj = proj.clone();
50    std::thread::spawn(move || {
51        while let Ok(event) = sync_rx.recv() {
52            match Watched::try_new(&event, &proj) {
53                Ok(Some(watched)) => handle(watched, proj.clone()),
54                Err(e) => log::error!("Notify error {e}"),
55                _ => log::trace!("Notify not handled {}", GRAY.paint(format!("{:?}", event))),
56            }
57        }
58        log::debug!("Notify stopped");
59    });
60
61    let mut watcher = notify::watcher(sync_tx, Duration::from_millis(200)).expect("failed to build file system watcher");
62
63    for path in paths {
64        if let Err(e) = watcher.watch(path, RecursiveMode::Recursive) {
65            log::error!("Notify could not watch {path:?} due to {e:?}");
66        }
67    }
68
69    if let Err(e) = Interrupt::subscribe_shutdown().recv().await {
70        log::trace!("Notify stopped due to: {e:?}");
71    }
72}
73
74fn handle(watched: Watched, proj: Arc<Project>) {
75    log::trace!("Notify handle {}", GRAY.paint(format!("{:?}", watched.path())));
76
77    let Some(path) = watched.path() else {
78        Interrupt::send_all_changed();
79        return;
80    };
81
82    let mut changes = Vec::new();
83
84    if let Some(assets) = &proj.assets {
85        if path.starts_with(&assets.dir) {
86            log::debug!("Notify asset change {}", GRAY.paint(watched.to_string()));
87            changes.push(Change::Asset(watched.clone()));
88        }
89    }
90
91    let lib_rs = path.starts_with_any(&proj.lib.src_paths) && path.is_ext_any(&["rs"]);
92    let lib_js = path.starts_with(&proj.js_dir) && path.is_ext_any(&["js"]);
93
94    if lib_rs || lib_js {
95        log::debug!("Notify lib source change {}", GRAY.paint(watched.to_string()));
96        changes.push(Change::LibSource);
97    }
98
99    if path.starts_with_any(&proj.bin.src_paths) && path.is_ext_any(&["rs"]) {
100        log::debug!("Notify bin source change {}", GRAY.paint(watched.to_string()));
101        changes.push(Change::BinSource);
102    }
103
104    if let Some(file) = &proj.style.file {
105        let src = file.source.clone().without_last();
106        if path.starts_with(src) && path.is_ext_any(&["scss", "sass", "css"]) {
107            log::debug!("Notify style change {}", GRAY.paint(watched.to_string()));
108            changes.push(Change::Style)
109        }
110    }
111
112    if let Some(tailwind) = &proj.style.tailwind {
113        if path.as_path() == tailwind.config_file.as_path() || path.as_path() == tailwind.input_file.as_path() {
114            log::debug!("Notify style change {}", GRAY.paint(watched.to_string()));
115            changes.push(Change::Style)
116        }
117    }
118
119    if !changes.is_empty() {
120        Interrupt::send(&changes);
121    } else {
122        log::trace!("Notify changed but not watched: {}", GRAY.paint(watched.to_string()));
123    }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
127pub enum Watched {
128    Remove(Utf8PathBuf),
129    Rename(Utf8PathBuf, Utf8PathBuf),
130    Write(Utf8PathBuf),
131    Create(Utf8PathBuf),
132    Rescan,
133}
134
135fn convert(p: &Path, proj: &Project) -> Result<Utf8PathBuf> {
136    let p = Utf8PathBuf::from_path_buf(p.to_path_buf()).map_err(|e| anyhow!("Could not convert to a Utf8PathBuf: {e:?}"))?;
137    Ok(p.unbase(&proj.working_dir).unwrap_or(p))
138}
139
140impl Watched {
141    pub(crate) fn try_new(event: &DebouncedEvent, proj: &Project) -> Result<Option<Self>> {
142        use DebouncedEvent::{Chmod, Create, Error, NoticeRemove, NoticeWrite, Remove, Rename, Rescan, Write};
143
144        Ok(match event {
145            Chmod(_) | NoticeRemove(_) | NoticeWrite(_) => None,
146            Create(f) => Some(Self::Create(convert(f, proj)?)),
147            Remove(f) => Some(Self::Remove(convert(f, proj)?)),
148            Rename(f, t) => Some(Self::Rename(convert(f, proj)?, convert(t, proj)?)),
149            Write(f) => Some(Self::Write(convert(f, proj)?)),
150            Rescan => Some(Self::Rescan),
151            Error(e, Some(p)) => {
152                log::error!("Notify error watching {p:?}: {e:?}");
153                None
154            }
155            Error(e, None) => {
156                log::error!("Notify error: {e:?}");
157                None
158            }
159        })
160    }
161
162    pub fn path_ext(&self) -> Option<&str> {
163        self.path().and_then(|p| p.extension())
164    }
165
166    pub fn path(&self) -> Option<&Utf8PathBuf> {
167        match self {
168            Self::Remove(p) | Self::Rename(p, _) | Self::Write(p) | Self::Create(p) => Some(p),
169            Self::Rescan => None,
170        }
171    }
172
173    pub fn path_starts_with(&self, path: &Utf8PathBuf) -> bool {
174        match self {
175            Self::Write(p) | Self::Create(p) | Self::Remove(p) => p.starts_with(path),
176            Self::Rename(fr, to) => fr.starts_with(path) || to.starts_with(path),
177            Self::Rescan => false,
178        }
179    }
180
181    pub fn path_starts_with_any(&self, paths: &[&Utf8PathBuf]) -> bool {
182        paths.iter().any(|path| self.path_starts_with(path))
183    }
184}
185
186impl Display for Watched {
187    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188        match self {
189            Self::Create(p) => write!(f, "create {p:?}"),
190            Self::Remove(p) => write!(f, "remove {p:?}"),
191            Self::Write(p) => write!(f, "write {p:?}"),
192            Self::Rename(fr, to) => write!(f, "rename {fr:?} -> {to:?}"),
193            Self::Rescan => write!(f, "rescan"),
194        }
195    }
196}