1
Notes
Andrew Briscoe edited this page 2025-09-20 06:39:25 +08:00
Table of Contents
Notes
#!/usr/bin/env -S rust-script
//! ```cargo
//! [package]
//! name = "fsmon-tui"
//! version = "1.0.0"
//! edition = "2021"
//!
//! [dependencies]
//! anyhow = "1.0"
//! async-trait = "0.1"
//! clap = { version = "4.5", features = ["derive"] }
//! crossterm = { version = "0.28", features = ["event-stream"] }
//! notify = "6.1"
//! ratatui = "0.29"
//! shellexpand = "3.1"
//! tokio = { version = "1.40", features = ["full", "signal"] }
//! tokio-stream = "0.1"
//! ```
#![allow(clippy::type_complexity)]
// ─────────────────────────────────────────────
// IMPORTS
// ─────────────────────────────────────────────
use anyhow::{Context, Result};
use async_trait::async_trait;
use clap::Parser;
use crossterm::{
event::{self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode, KeyEvent, KeyModifiers},
execute,
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use notify::{event::RemoveKind, Config, Event as NEvent, EventKind, RecommendedWatcher, Watcher};
use ratatui::{
backend::CrosstermBackend,
layout::Constraint,
prelude::*,
style::{Color, Modifier, Style},
text::{Line, Span, Text},
widgets::{Block, Borders, List, ListItem, Paragraph},
Terminal,
};
use std::{
collections::{BinaryHeap, HashMap},
io::{self, Write},
path::{Path, PathBuf},
sync::Arc,
time::{Duration, Instant},
};
use tokio::{
sync::{mpsc, RwLock},
time::interval,
};
use tokio_stream::StreamExt;
// ─────────────────────────────────────────────
// CONFIG — Easy to extend later
// ─────────────────────────────────────────────
#[derive(Parser, Debug, Clone)]
#[command(
author,
version,
about = "Live filesystem monitor with TUI — observe idempotency, bootstrapping, Git plumbing",
long_about = None
)]
pub struct ConfigArgs {
/// Path to watch (default: current directory)
#[arg(short, long, default_value = ".")]
pub watch: String,
/// Show hidden files (starting with '.')
#[arg(long, default_value_t = false)]
pub hidden: bool,
/// Max age (seconds) to show modified files (0 = no limit)
#[arg(long, default_value_t = 60.0)]
pub max_age: f32,
/// Refresh rate in milliseconds
#[arg(long, default_value_t = 50u64)]
pub refresh_rate: u64,
/// Enable debug logging to stderr
#[arg(long, default_value_t = false)]
pub debug: bool,
}
// ─────────────────────────────────────────────
// DOMAIN LOGIC — Resource Events & State
// ─────────────────────────────────────────────
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum ResourceEvent {
Created { path: PathBuf, is_dir: bool, at: Instant },
Removed { path: PathBuf, is_dir: bool, kind: RemoveKind, at: Instant },
Modified { path: PathBuf, at: Instant },
Accessed { path: PathBuf, at: Instant },
Error { msg: String, at: Instant },
}
impl ResourceEvent {
pub fn new_created(path: PathBuf, is_dir: bool) -> Self {
Self::Created { path, is_dir, at: Instant::now() }
}
pub fn new_removed(path: PathBuf, is_dir: bool, kind: RemoveKind) -> Self {
Self::Removed { path, is_dir, kind, at: Instant::now() }
}
pub fn new_modified(path: PathBuf) -> Self {
Self::Modified { path, at: Instant::now() }
}
pub fn new_accessed(path: PathBuf) -> Self {
Self::Accessed { path, at: Instant::now() }
}
pub fn new_error(msg: String) -> Self {
Self::Error { msg, at: Instant::now() }
}
pub fn path(&self) -> Option<&PathBuf> {
match self {
ResourceEvent::Created { path, .. }
| ResourceEvent::Removed { path, .. }
| ResourceEvent::Modified { path }
| ResourceEvent::Accessed { path } => Some(path),
_ => None,
}
}
pub fn is_hidden(&self) -> bool {
self.path().map_or(false, |p| {
p.file_name()
.and_then(|n| n.to_str())
.map_or(false, |s| s.starts_with('.'))
})
}
pub fn age(&self) -> f32 {
let now = Instant::now();
match self {
ResourceEvent::Created { at, .. }
| ResourceEvent::Removed { at, .. }
| ResourceEvent::Modified { at }
| ResourceEvent::Accessed { at }
| ResourceEvent::Error { at, .. } => now.duration_since(*at).as_secs_f32(),
}
}
pub fn priority(&self) -> i64 {
// Newer = higher priority (BinaryHeap is max-heap)
-(self.age() as i64)
}
}
#[derive(Debug, Clone)]
pub struct FileState {
pub count: u32,
pub last_modified: Instant,
pub created: bool,
pub exists: bool,
}
#[derive(Debug, Clone, Default)]
pub struct ResourceState {
pub files: HashMap<PathBuf, FileState>,
pub deleted: HashMap<PathBuf, Instant>,
pub event_count: usize,
pub recent_events: BinaryHeap<(i64, ResourceEvent)>, // priority, event
pub max_event_age: f32,
}
impl ResourceState {
pub fn push_event(&mut self, event: ResourceEvent, max_age: f32) {
let age = event.age();
if age <= max_age || matches!(event, ResourceEvent::Removed { .. }) {
self.recent_events.push((event.priority(), event));
}
self.event_count += 1;
}
pub fn prune_old_events(&mut self, max_age: f32) {
let now = Instant::now();
self.deleted.retain(|_, at| now.duration_since(*at).as_secs_f32() < 60.0);
let mut temp = BinaryHeap::new();
while let Some((_, event)) = self.recent_events.pop() {
if event.age() <= max_age || matches!(event, ResourceEvent::Removed { .. }) {
temp.push((event.priority(), event));
}
}
self.recent_events = temp;
}
}
// ─────────────────────────────────────────────
// BUSINESS LOGIC — Morphisms & Pipeline
// ─────────────────────────────────────────────
#[async_trait]
pub trait ResourceMorphism: Send + Sync {
async fn apply(&self, state: ResourceState, event: ResourceEvent) -> ResourceState;
}
pub struct CreateTracker;
#[async_trait]
impl ResourceMorphism for CreateTracker {
async fn apply(&self, mut state: ResourceState, event: ResourceEvent) -> ResourceState {
if let ResourceEvent::Created { path, .. } = &event {
let now = Instant::now();
let entry = state.files.entry(path.clone()).or_insert(FileState {
count: 0,
last_modified: now,
created: true,
exists: true,
});
entry.count += 1;
entry.last_modified = now;
entry.created = true;
entry.exists = true;
}
state.push_event(event, state.max_event_age);
state
}
}
pub struct ModifyTracker;
#[async_trait]
impl ResourceMorphism for ModifyTracker {
async fn apply(&self, mut state: ResourceState, event: ResourceEvent) -> ResourceState {
if let ResourceEvent::Modified { path } = &event {
let now = Instant::now();
let entry = state.files.entry(path.clone()).or_insert(FileState {
count: 0,
last_modified: now,
created: false,
exists: true,
});
entry.count += 1;
entry.last_modified = now;
entry.created = false;
entry.exists = true;
}
state.push_event(event, state.max_event_age);
state
}
}
pub struct RemoveTracker;
#[async_trait]
impl ResourceMorphism for RemoveTracker {
async fn apply(&self, mut state: ResourceState, event: ResourceEvent) -> ResourceState {
if let ResourceEvent::Removed { path, .. } = &event {
if let Some(entry) = state.files.get_mut(path) {
entry.exists = false;
}
state.deleted.insert(path.clone(), Instant::now());
}
state.push_event(event, state.max_event_age);
state
}
}
pub struct Pipeline {
morphisms: Vec<Box<dyn ResourceMorphism>>,
}
impl Pipeline {
pub fn new() -> Self {
Self { morphisms: Vec::new() }
}
pub fn add<T: ResourceMorphism + 'static>(mut self, morphism: T) -> Self {
self.morphisms.push(Box::new(morphism));
self
}
pub async fn apply(&self, mut state: ResourceState, event: ResourceEvent) -> ResourceState {
for morphism in &self.morphisms {
state = morphism.apply(state, event.clone()).await;
}
state
}
}
// ─────────────────────────────────────────────
// TUI RENDERER — Clean, Fading, Priority-Based
// ─────────────────────────────────────────────
fn render_ui(frame: &mut Frame, watch_path: &Path, state: &ResourceState, config: &ConfigArgs) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(3),
Constraint::Min(1),
Constraint::Length(1),
])
.split(frame.area());
// Header
let header = Paragraph::new(Line::from(vec![
Span::styled("fsmon-tui — Watching: ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::raw(watch_path.display().to_string()),
]))
.block(Block::default().borders(Borders::BOTTOM));
frame.render_widget(header, chunks[0]);
// Event List
let mut events: Vec<ListItem> = Vec::new();
let mut temp_events = state.recent_events.clone();
let now = Instant::now();
while let Some((_, event)) = temp_events.pop() {
let age = event.age();
let (style, prefix) = match &event {
ResourceEvent::Created { .. } => (
Style::default().fg(Color::Green),
Span::styled("[NEW] ", Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)),
),
ResourceEvent::Modified { .. } => {
let intensity = (1.0 - (age / state.max_event_age).min(1.0)) as u8 * 12;
let color = Color::Rgb(180 - intensity, 180 - intensity, 180 - intensity); // fade to gray
(
Style::default().fg(color),
Span::styled("[MOD] ", Style::default().fg(Color::Blue)),
)
}
ResourceEvent::Removed { .. } => (
Style::default().bg(Color::Red).fg(Color::White),
Span::styled("[DEL] ", Style::default().fg(Color::White).add_modifier(Modifier::BOLD)),
),
ResourceEvent::Accessed { .. } => (
Style::default().fg(Color::Gray),
Span::styled("[ACC] ", Style::default().fg(Color::DarkGray)),
),
ResourceEvent::Error { .. } => (
Style::default().fg(Color::Red),
Span::styled("[ERR] ", Style::default().fg(Color::Red).add_modifier(Modifier::BOLD)),
),
};
let path_str = event.path().map_or_else(|| "—".to_string(), |p| p.display().to_string());
let line = Line::from(vec![
prefix,
Span::styled(path_str, style),
Span::styled(format!(" ({:.1}s)", age), Style::default().fg(Color::DarkGray)),
]);
events.push(ListItem::new(line));
}
events.reverse(); // Newest on top
let list = List::new(events)
.block(Block::default().title(format!(" Recent Activity (last {}s) ", state.max_event_age)).borders(Borders::ALL))
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
frame.render_widget(list, chunks[1]);
// Footer
let footer_text = if config.debug {
Text::from(Line::from(vec![
Span::styled("DEBUG MODE ON ", Style::default().fg(Color::Red)),
Span::raw(" • "),
Span::raw("Press "),
Span::styled("q/Esc/Ctrl+C", Style::default().fg(Color::Yellow)),
Span::raw(" to quit • Events: "),
Span::styled(format!("{}", state.event_count), Style::default().fg(Color::Magenta)),
]))
} else {
Text::from(Line::from(vec![
Span::raw("Press "),
Span::styled("q/Esc/Ctrl+C", Style::default().fg(Color::Yellow)),
Span::raw(" to quit • Events: "),
Span::styled(format!("{}", state.event_count), Style::default().fg(Color::Magenta)),
]))
};
let footer = Paragraph::new(footer_text).block(Block::default().borders(Borders::TOP));
frame.render_widget(footer, chunks[2]);
}
// ─────────────────────────────────────────────
// EVENT CONVERSION
// ─────────────────────────────────────────────
fn convert_notify_event(event: NEvent) -> Option<ResourceEvent> {
match event.kind {
EventKind::Create(_) => event.paths.first().map(|p| ResourceEvent::new_created(p.clone(), p.is_dir())),
EventKind::Remove(kind) => event.paths.first().map(|p| ResourceEvent::new_removed(p.clone(), p.is_dir(), kind)),
EventKind::Modify(_) => event.paths.first().map(|p| ResourceEvent::new_modified(p.clone())),
EventKind::Access(_) => event.paths.first().map(|p| ResourceEvent::new_accessed(p.clone())),
_ => None,
}
}
// ─────────────────────────────────────────────
// TUI RUNNER — Async, Clean, Responsive
// ─────────────────────────────────────────────
async fn run_tui(state: Arc<RwLock<ResourceState>>, watch_path: &Path, config: ConfigArgs) -> Result<()> {
let mut term = TermGuard::enter().context("Failed to setup terminal")?;
term.terminal.clear()?;
// Initial render
{
let app_state = state.read().await;
term.terminal.draw(|f| render_ui(f, watch_path, &app_state, &config))?;
}
let mut event_stream = event::EventStream::new();
let mut refresh_interval = interval(Duration::from_millis(config.refresh_rate));
loop {
tokio::select! {
maybe_event = event_stream.next() => {
match maybe_event {
Some(Ok(CEvent::Key(KeyEvent { code, modifiers }))) => {
if config.debug {
eprintln!("🔑 Key: {:?}", code);
}
let should_quit = match code {
KeyCode::Char('q') => true,
KeyCode::Esc => true,
KeyCode::Char('c') => modifiers.contains(KeyModifiers::CONTROL),
_ => false,
};
if should_quit {
if config.debug {
eprintln!("🚪 Exiting...");
}
return Ok(());
}
}
Some(Ok(CEvent::Resize(_, _))) => {}
Some(Ok(_)) => {}
Some(Err(e)) => {
if config.debug {
eprintln!("⚠️ Event error: {:?}", e);
}
}
None => break,
}
}
_ = refresh_interval.tick() => {
{
let mut state_mut = state.write().await;
state_mut.prune_old_events(config.max_age);
}
let app_state = state.read().await;
term.terminal.draw(|f| render_ui(f, watch_path, &app_state, &config))?;
}
}
}
Ok(())
}
// ─────────────────────────────────────────────
// TERMINAL GUARD & PANIC HANDLER
// ─────────────────────────────────────────────
struct TermGuard {
terminal: Terminal<CrosstermBackend<std::io::Stdout>>,
}
impl TermGuard {
fn enter() -> Result<Self> {
enable_raw_mode().context("Failed to enable raw mode")?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
let backend = CrosstermBackend::new(stdout);
let terminal = Terminal::new(backend)?;
Ok(Self { terminal })
}
}
impl Drop for TermGuard {
fn drop(&mut self) {
let _ = disable_raw_mode();
let _ = execute!(self.terminal.backend_mut(), LeaveAlternateScreen, DisableMouseCapture);
let _ = self.terminal.show_cursor();
let _ = io::stdout().flush();
}
}
fn install_panic_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let _ = disable_raw_mode();
let _ = execute!(io::stdout(), LeaveAlternateScreen, DisableMouseCapture);
default(info);
}));
}
// ─────────────────────────────────────────────
// MAIN — Orchestration
// ─────────────────────────────────────────────
#[tokio::main]
async fn main() -> Result<()> {
install_panic_hook();
let config = ConfigArgs::parse();
// Spawn Ctrl+C handler
let ctrl_c_config = config.clone();
tokio::spawn(async move {
tokio::signal::ctrl_c().await.unwrap();
if ctrl_c_config.debug {
eprintln!("👋 SIGINT received, shutting down...");
}
std::process::exit(0);
});
let pipeline = Pipeline::new().add(CreateTracker).add(ModifyTracker).add(RemoveTracker);
let state = Arc::new(RwLock::new(ResourceState {
max_event_age: config.max_age,
..Default::default()
}));
let (tx, mut rx) = mpsc::unbounded_channel::<NEvent>();
let watch_path = shellexpand::tilde(&config.watch).into_owned();
let canonical_path = PathBuf::from(watch_path).canonicalize().context("Failed to resolve watch path")?;
if !canonical_path.exists() {
anyhow::bail!("Watch path does not exist: {}", canonical_path.display());
}
if !canonical_path.is_dir() {
anyhow::bail!("Watch path is not a directory: {}", canonical_path.display());
}
let mut watcher = RecommendedWatcher::new(
{
let tx = tx.clone();
move |res: Result<NEvent, notify::Error>| {
if let Ok(event) = res {
let _ = tx.send(event);
}
}
},
Config::default(),
)?;
watcher.watch(&canonical_path, notify::RecursiveMode::Recursive)?;
{
let state_processor = state.clone();
let hidden_flag = config.hidden;
tokio::spawn(async move {
while let Some(notify_event) = rx.recv().await {
if let Some(resource_event) = convert_notify_event(notify_event) {
if !hidden_flag && resource_event.is_hidden() {
continue;
}
let current = state_processor.read().await.clone();
let new_state = pipeline.apply(current, resource_event).await;
*state_processor.write().await = new_state;
}
}
});
}
run_tui(state, &canonical_path, config).await?;
drop(watcher);
Ok(())
}
// ─────────────────────────────────────────────
// TESTS
// ─────────────────────────────────────────────
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_event_priority() {
let e1 = ResourceEvent::new_modified(PathBuf::from("a.txt"));
std::thread::sleep(Duration::from_millis(10));
let e2 = ResourceEvent::new_modified(PathBuf::from("b.txt"));
assert!(e1.priority() < e2.priority()); // e2 is newer → higher priority
}
#[tokio::test]
async fn test_pipeline() {
let pipeline = Pipeline::new().add(CreateTracker);
let state = ResourceState::default();
let event = ResourceEvent::new_created(PathBuf::from("test.txt"), false);
let new_state = pipeline.apply(state, event).await;
assert_eq!(new_state.files.len(), 1);
}
}
DOCUMENTATION — Man Page, Tech Design, Requirements, Retrospective
==================================================
MAN PAGE — fsmon-tui(1)
==================================================
NAME
fsmon-tui — Live filesystem monitor with TUI for observing idempotency and bootstrapping
SYNOPSIS
fsmon-tui [OPTIONS]
DESCRIPTION
fsmon-tui watches a directory and renders filesystem events in real-time using a terminal UI.
Events fade over time. Deleted files remain visible for 60s.
OPTIONS
-w, --watch PATH
Directory to watch (default: ".")
--hidden
Include hidden files (starting with '.')
--max-age SECONDS
Show events up to this age (default: 60)
--refresh-rate MS
UI refresh rate in milliseconds (default: 50)
--debug
Enable debug logging to stderr
-h, --help
Print help
-V, --version
Print version
KEYS
q, Esc, Ctrl+C → Quit
EXAMPLES
fsmon-tui --watch /tmp --max-age 30
fsmon-tui --hidden --debug
==========================
TECHNICAL DESIGN DOCUMENT
=========================
GOAL
Build a live filesystem observability tool to:
- Debug idempotency in orchestration systems
- Observe self-bootstrapping behavior
- Map Git commands to filesystem deltas
- Serve as foundation for TF-IDF/category-theoretic analysis
ARCHITECTURE
- Domain Layer: ResourceEvent, ResourceState — pure data
- Business Layer: Pipeline, Morphisms — async state transitions
- UI Layer: TUI renderer — priority-based, fading, clean
EXTENSIBILITY
- Add filters/search via new ConfigArgs field + UI input box
- Integrate with external tools via event export (JSON, IPC)
- Add “recording” mode to log events to file for replay
PERFORMANCE
- Uses async event stream (tokio + crossterm)
- Prunes old events to limit memory
- 20fps UI refresh (configurable)
==================================================
REQUIREMENTS
==================================================
FUNCTIONAL
[x] Watch directory recursively
[x] Render TUI immediately on startup
[x] Show create/modify/remove events
[x] Fade older events to gray
[x] Keep deleted files visible for 60s
[x] Respond to q/Esc/Ctrl+C to quit
[x] Configurable via CLI
NON-FUNCTIONAL
[x] Low latency — respond to events within 50ms
[x] Memory bounded — prune old events
[x] Cross-platform — Linux/macOS/Windows (via notify-rs)
[x] Zero external dependencies — single file Rust script
FUTURE
[ ] Search/filter bar
[ ] Export event log
[ ] Git command inference
[ ] TF-IDF dense subgraph overlay
==================================================
RETROSPECTIVE
==================================================
WHAT WENT WELL
- Async TUI with EventStream is responsive and reliable
- Priority queue + fading gives excellent visual feedback
- Category-theoretic design made pipeline composable
- Single-file distribution via rust-script is elegant
WHAT TO IMPROVE
- Add unit tests for UI rendering (via ratatui test backend)
- Support pausing/resuming event capture
- Add theming (light/dark mode)
LESSONS LEARNED
- Never put match inside tokio::select! — syntax trap
- Always drain event queues — don’t read one event per tick
- Fading by RGB interpolation is simple and effective
- Config at top + layered architecture = easy to extend