use std::future::Future;
use std::path::{Path, PathBuf};
use std::pin::Pin;
use std::time::UNIX_EPOCH;
use axum::{
http::StatusCode,
response::{
IntoResponse, Response,
sse::{Event, Sse},
},
};
use futures::StreamExt;
use objectiveai_sdk::laboratories::filetree::{FileTreeEvent, FileTreeNode};
static IGNORE: std::sync::OnceLock<Vec<PathBuf>> = std::sync::OnceLock::new();
pub(crate) fn init_ignore_env(raw: Option<&str>) {
let _ = IGNORE.set(
raw.unwrap_or_default()
.split(':')
.filter(|entry| entry.starts_with('/') && *entry != "/")
.map(PathBuf::from)
.collect(),
);
}
fn ignore() -> &'static [PathBuf] {
IGNORE.get_or_init(Vec::new)
}
fn is_excluded(path: &Path) -> bool {
ignore().iter().any(|p| path.starts_with(p))
}
fn subtree_may_contain_excluded(dir: &Path) -> bool {
ignore().iter().any(|p| p.starts_with(dir))
}
fn watch_resilient<'a>(
watcher: &'a std::sync::Mutex<notify::RecommendedWatcher>,
dir: &'a Path,
) -> Pin<Box<dyn Future<Output = notify::Result<()>> + Send + 'a>> {
Box::pin(async move {
use notify::Watcher;
if is_excluded(dir) {
return Ok(());
}
if !subtree_may_contain_excluded(dir)
&& watcher
.lock()
.expect("watcher lock")
.watch(dir, notify::RecursiveMode::Recursive)
.is_ok()
{
return Ok(());
}
watcher
.lock()
.expect("watcher lock")
.watch(dir, notify::RecursiveMode::NonRecursive)?;
let Ok(mut read) = tokio::fs::read_dir(dir).await else {
return Ok(());
};
let mut entries = Vec::new();
while let Ok(Some(entry)) = read.next_entry().await {
entries.push(entry);
}
futures::future::join_all(entries.into_iter().map(|entry| async move {
let path = entry.path();
let is_dir = entry
.file_type()
.await
.is_ok_and(|t| t.is_dir());
if is_dir {
let _ = watch_resilient(watcher, &path).await;
}
}))
.await;
Ok(())
})
}
pub async fn filetree() -> Response {
let root = PathBuf::from("/");
let (tx, rx) = futures::channel::mpsc::unbounded::<notify::Result<notify::Event>>();
let watcher = match notify::recommended_watcher(move |res| {
let _ = tx.unbounded_send(res);
}) {
Ok(w) => w,
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("watch init: {e}"),
)
.into_response();
}
};
let watcher = std::sync::Arc::new(std::sync::Mutex::new(watcher));
if let Err(e) = watch_resilient(&watcher, &root).await {
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("watch {}: {e}", root.display()),
)
.into_response();
}
let snapshot_children = build_children(&root).await;
let snapshot_event = sse_event(&FileTreeEvent::Snapshot {
children: snapshot_children,
});
let deltas = rx
.then(move |res| {
let watcher = std::sync::Arc::clone(&watcher);
let root = root.clone();
async move {
match res {
Ok(event) => events_to_deltas(&root, event, &watcher).await,
Err(_) => vec![sse_event(&FileTreeEvent::Snapshot {
children: build_children(&root).await,
})],
}
}
})
.flat_map(|events| {
futures::stream::iter(
events
.into_iter()
.map(Ok::<Event, std::convert::Infallible>),
)
});
let stream = futures::stream::once(async move { Ok(snapshot_event) }).chain(deltas);
Sse::new(stream).into_response()
}
fn sse_event(event: &FileTreeEvent) -> Event {
Event::default().data(serde_json::to_string(event).unwrap_or_default())
}
async fn events_to_deltas(
root: &Path,
event: notify::Event,
watcher: &std::sync::Arc<std::sync::Mutex<notify::RecommendedWatcher>>,
) -> Vec<Event> {
use notify::EventKind;
let mut out = Vec::new();
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
for path in event.paths {
if is_excluded(&path) {
continue;
}
let Some(components) = rel_components(root, &path) else {
continue;
};
match build_node(&path).await {
Some(node) => {
if matches!(node, FileTreeNode::Directory { .. }) {
let _ = watch_resilient(watcher, &path).await;
}
out.push(sse_event(&FileTreeEvent::Upserted {
path: components,
node,
}));
}
None => out.push(sse_event(&FileTreeEvent::Removed {
path: components,
})),
}
}
}
EventKind::Remove(_) => {
for path in event.paths {
if is_excluded(&path) {
continue;
}
if let Some(components) = rel_components(root, &path) {
out.push(sse_event(&FileTreeEvent::Removed { path: components }));
}
}
}
_ => {}
}
out
}
fn rel_components(root: &Path, path: &Path) -> Option<Vec<String>> {
let rel = pathdiff::diff_paths(path, root)?;
if rel.as_os_str().is_empty() || rel.starts_with("..") {
return None;
}
Some(
rel.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect(),
)
}
async fn build_node(path: &Path) -> Option<FileTreeNode> {
let meta = tokio::fs::symlink_metadata(path).await.ok()?;
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
let ft = meta.file_type();
if ft.is_dir() {
Some(dir_node(path, name, &meta, build_children(path).await))
} else {
Some(leaf_node(path, name, ft.is_symlink(), &meta).await)
}
}
fn build_children(
dir: &Path,
) -> Pin<Box<dyn Future<Output = Vec<FileTreeNode>> + Send + '_>> {
Box::pin(async move {
let Ok(mut read) = tokio::fs::read_dir(dir).await else {
return Vec::new();
};
let mut entries = Vec::new();
while let Ok(Some(entry)) = read.next_entry().await {
entries.push(entry);
}
futures::future::join_all(entries.into_iter().map(|entry| async move {
let path = entry.path();
if is_excluded(&path) {
return None;
}
let meta = entry.metadata().await.ok()?;
let name = entry.file_name().to_string_lossy().into_owned();
let ft = meta.file_type();
Some(if ft.is_dir() {
dir_node(&path, name, &meta, build_children(&path).await)
} else {
leaf_node(&path, name, ft.is_symlink(), &meta).await
})
}))
.await
.into_iter()
.flatten()
.collect()
})
}
async fn leaf_node(
path: &Path,
name: String,
is_symlink: bool,
meta: &std::fs::Metadata,
) -> FileTreeNode {
let created_at = unix_secs(meta.created().ok());
let modified_at = unix_secs(meta.modified().ok());
let attr = crate::attribution::lookup(path);
if is_symlink {
FileTreeNode::Symlink {
name,
target: tokio::fs::read_link(path)
.await
.ok()
.map(|t| t.to_string_lossy().into_owned()),
created_at,
modified_at,
created_by: attr.created_by,
modified_by: attr.modified_by,
}
} else {
FileTreeNode::File {
name,
size: Some(meta.len()),
created_at,
modified_at,
created_by: attr.created_by,
modified_by: attr.modified_by,
}
}
}
fn dir_node(
path: &Path,
name: String,
meta: &std::fs::Metadata,
children: Vec<FileTreeNode>,
) -> FileTreeNode {
let attr = crate::attribution::lookup(path);
FileTreeNode::Directory {
name,
created_at: unix_secs(meta.created().ok()),
modified_at: unix_secs(meta.modified().ok()),
created_by: attr.created_by,
modified_by: attr.modified_by,
children,
}
}
fn unix_secs(time: Option<std::time::SystemTime>) -> Option<i64> {
time?
.duration_since(UNIX_EPOCH)
.ok()
.map(|d| d.as_secs() as i64)
}