1use std::collections::HashSet;
5use std::io::Read as _;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8use std::time::Duration;
9
10use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
11use tokio::sync::mpsc;
12use zeph_common::{TaskSupervisor, task_supervisor::BlockingHandle};
13
14use crate::config::ProviderKind;
15
16#[non_exhaustive]
17pub enum InstructionEvent {
18 Changed,
19}
20
21pub struct InstructionWatcher {
22 _handle: BlockingHandle<()>,
23}
24
25impl InstructionWatcher {
26 pub fn start(
34 paths: &[PathBuf],
35 tx: mpsc::Sender<InstructionEvent>,
36 supervisor: &Arc<TaskSupervisor>,
37 ) -> Result<Self, notify::Error> {
38 let (notify_tx, mut notify_rx) = mpsc::channel(16);
39
40 let mut debouncer = new_debouncer(
41 Duration::from_millis(500),
42 move |events: Result<Vec<notify_debouncer_mini::DebouncedEvent>, notify::Error>| {
43 let events = match events {
44 Ok(events) => events,
45 Err(e) => {
46 tracing::warn!("instruction watcher error: {e}");
47 return;
48 }
49 };
50
51 let has_md_change = events.iter().any(|e| {
52 e.kind == DebouncedEventKind::Any
53 && e.path.extension().is_some_and(|ext| ext == "md")
54 });
55
56 if has_md_change {
57 let _ = notify_tx.try_send(());
58 }
59 },
60 )?;
61
62 for path in paths {
63 if path.exists()
64 && let Err(e) = debouncer
65 .watcher()
66 .watch(path, notify::RecursiveMode::NonRecursive)
67 {
68 tracing::warn!(path = %path.display(), error = %e, "failed to watch instruction path");
69 }
70 }
71
72 tracing::debug!(paths = paths.len(), "starting instruction watcher");
73 let handle = supervisor.spawn_oneshot(
74 std::sync::Arc::from("core.instruction_watcher"),
75 move || async move {
76 let _debouncer = debouncer;
77 while notify_rx.recv().await.is_some() {
78 tracing::debug!("instruction file change detected, signaling reload");
79 if tx.send(InstructionEvent::Changed).await.is_err() {
80 break;
81 }
82 }
83 },
84 );
85
86 Ok(Self { _handle: handle })
87 }
88}
89
90pub struct InstructionReloadState {
92 pub base_dir: PathBuf,
93 pub provider_kinds: Vec<ProviderKind>,
94 pub explicit_files: Vec<PathBuf>,
95 pub auto_detect: bool,
96}
97
98const MAX_FILE_SIZE: u64 = 256 * 1024; #[derive(Debug, Clone)]
103pub struct InstructionBlock {
104 pub source: PathBuf,
106 pub content: String,
108}
109
110pub fn load_instructions(
126 base_dir: &Path,
127 provider_kinds: &[ProviderKind],
128 explicit_files: &[PathBuf],
129 auto_detect: bool,
130) -> Vec<InstructionBlock> {
131 let canonical_base = match std::fs::canonicalize(base_dir) {
132 Ok(c) => c,
133 Err(e) => {
134 tracing::warn!(path = %base_dir.display(), error = %e, "failed to canonicalize base_dir, skipping all instruction files");
135 return Vec::new();
136 }
137 };
138
139 let mut candidates: Vec<PathBuf> = Vec::new();
140
141 candidates.push(base_dir.join("zeph.md"));
143 candidates.push(base_dir.join(".zeph").join("zeph.md"));
144
145 if auto_detect {
146 for &kind in provider_kinds {
147 candidates.extend(detection_paths(kind, base_dir));
148 }
149 }
150
151 for p in explicit_files {
153 if p.is_absolute() {
154 candidates.push(p.clone());
155 } else {
156 candidates.push(base_dir.join(p));
157 }
158 }
159
160 let mut seen: HashSet<PathBuf> = HashSet::new();
162 let mut result: Vec<InstructionBlock> = Vec::new();
163
164 for path in candidates {
165 let Ok(canonical) = std::fs::canonicalize(&path) else {
168 continue;
169 };
170
171 if !canonical.starts_with(&canonical_base) {
172 tracing::warn!(path = %canonical.display(), "instruction file escapes project root, skipping");
173 continue;
174 }
175
176 if !seen.insert(canonical.clone()) {
177 continue;
179 }
180
181 let Ok(file) = std::fs::File::open(&canonical) else {
183 continue;
184 };
185
186 let meta = match file.metadata() {
187 Ok(m) => m,
188 Err(e) => {
189 tracing::warn!(path = %path.display(), error = %e, "failed to read instruction file metadata, skipping");
190 continue;
191 }
192 };
193
194 if !meta.is_file() {
195 continue;
196 }
197
198 if meta.len() > MAX_FILE_SIZE {
199 tracing::warn!(
200 path = %path.display(),
201 size = meta.len(),
202 limit = MAX_FILE_SIZE,
203 "instruction file exceeds 256 KiB size limit, skipping"
204 );
205 continue;
206 }
207
208 let mut content = String::new();
209 match std::io::BufReader::new(file).read_to_string(&mut content) {
210 Ok(_) => {}
211 Err(e) => {
212 tracing::warn!(path = %path.display(), error = %e, "failed to read instruction file, skipping");
213 continue;
214 }
215 }
216
217 if content.contains('\0') {
218 tracing::warn!(path = %path.display(), "instruction file contains null bytes, skipping");
219 continue;
220 }
221
222 if content.is_empty() {
223 tracing::debug!(path = %path.display(), "instruction file is empty, skipping");
224 continue;
225 }
226
227 tracing::debug!(path = %path.display(), bytes = content.len(), "loaded instruction file");
228 result.push(InstructionBlock {
229 source: path,
230 content,
231 });
232 }
233
234 result
235}
236
237fn detection_paths(kind: ProviderKind, base: &Path) -> Vec<PathBuf> {
242 match kind {
243 ProviderKind::Claude => {
244 let mut paths = vec![
245 base.join("CLAUDE.md"),
246 base.join(".claude").join("CLAUDE.md"),
247 ];
248 let rules_dir = base.join(".claude").join("rules");
250 if let Ok(entries) = std::fs::read_dir(&rules_dir) {
251 let mut rule_files: Vec<PathBuf> = entries
252 .filter_map(std::result::Result::ok)
253 .map(|e| e.path())
254 .filter(|p| p.extension().is_some_and(|ext| ext == "md"))
255 .collect();
256 rule_files.sort();
257 paths.extend(rule_files);
258 }
259 paths
260 }
261 ProviderKind::OpenAi => {
262 vec![base.join("AGENTS.override.md"), base.join("AGENTS.md")]
263 }
264 ProviderKind::Compatible
265 | ProviderKind::Ollama
266 | ProviderKind::Candle
267 | ProviderKind::Gemini
268 | ProviderKind::Gonka
269 | ProviderKind::Cocoon => {
270 vec![base.join("AGENTS.md")]
271 }
272 _ => vec![base.join("AGENTS.md")],
273 }
274}
275
276pub async fn load_instructions_async(
281 base_dir: PathBuf,
282 provider_kinds: Vec<ProviderKind>,
283 explicit_files: Vec<PathBuf>,
284 auto_detect: bool,
285) -> Vec<InstructionBlock> {
286 tokio::task::spawn_blocking(move || {
287 load_instructions(&base_dir, &provider_kinds, &explicit_files, auto_detect)
288 })
289 .await
290 .unwrap_or_else(|e| {
291 tracing::error!(
292 error = %e,
293 "load_instructions_async: blocking task panicked, returning empty blocks"
294 );
295 Vec::new()
296 })
297}
298
299#[cfg(test)]
300mod watcher_tests {
301 use std::sync::Arc;
302
303 use tokio::sync::mpsc;
304 use tokio_util::sync::CancellationToken;
305 use zeph_common::TaskSupervisor;
306
307 use super::*;
308
309 fn make_supervisor() -> Arc<TaskSupervisor> {
310 Arc::new(TaskSupervisor::new(CancellationToken::new()))
311 }
312
313 #[tokio::test]
314 async fn start_with_valid_directory() {
315 let dir = tempfile::tempdir().unwrap();
316 let sup = make_supervisor();
317 let (tx, _rx) = mpsc::channel(16);
318 let result = InstructionWatcher::start(&[dir.path().to_path_buf()], tx, &sup);
319 assert!(result.is_ok());
320 }
321
322 #[tokio::test]
323 async fn start_with_empty_paths() {
324 let sup = make_supervisor();
325 let (tx, _rx) = mpsc::channel(16);
326 let result = InstructionWatcher::start(&[], tx, &sup);
327 assert!(result.is_ok());
328 }
329
330 #[tokio::test]
331 async fn detects_md_file_change() {
332 let dir = tempfile::tempdir().unwrap();
333 let sup = make_supervisor();
334 let (tx, mut rx) = mpsc::channel(16);
335 let _watcher = InstructionWatcher::start(&[dir.path().to_path_buf()], tx, &sup).unwrap();
336
337 let md_path = dir.path().join("zeph.md");
338 std::fs::write(&md_path, "initial").unwrap();
339
340 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
341 std::fs::write(&md_path, "updated").unwrap();
342
343 let result = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()).await;
344 assert!(
345 result.is_ok(),
346 "expected InstructionEvent::Changed within timeout"
347 );
348 }
349
350 #[tokio::test]
351 async fn ignores_non_md_file_change() {
352 let dir = tempfile::tempdir().unwrap();
353 let sup = make_supervisor();
354 let (tx, mut rx) = mpsc::channel(16);
355 let _watcher = InstructionWatcher::start(&[dir.path().to_path_buf()], tx, &sup).unwrap();
356
357 let other_path = dir.path().join("notes.txt");
358 std::fs::write(&other_path, "content").unwrap();
359
360 let result = tokio::time::timeout(std::time::Duration::from_millis(1500), rx.recv()).await;
361 assert!(result.is_err(), "should not receive event for non-.md file");
362 }
363
364 #[tokio::test]
365 async fn detects_md_file_deletion() {
366 let dir = tempfile::tempdir().unwrap();
367 let md_path = dir.path().join("zeph.md");
368 std::fs::write(&md_path, "content").unwrap();
369
370 let sup = make_supervisor();
371 let (tx, mut rx) = mpsc::channel(16);
372 let _watcher = InstructionWatcher::start(&[dir.path().to_path_buf()], tx, &sup).unwrap();
373
374 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
375 std::fs::remove_file(&md_path).unwrap();
376
377 let result = tokio::time::timeout(std::time::Duration::from_secs(3), rx.recv()).await;
378 assert!(
379 result.is_ok(),
380 "expected InstructionEvent::Changed on .md deletion"
381 );
382 }
383}
384
385#[cfg(test)]
386mod reload_tests {
387 use super::*;
388
389 #[test]
390 fn reload_returns_updated_blocks_when_file_changes() {
391 let dir = tempfile::tempdir().unwrap();
392 let md_path = dir.path().join("zeph.md");
393 std::fs::write(&md_path, "initial content").unwrap();
394
395 let blocks = load_instructions(dir.path(), &[], &[], false);
396 assert_eq!(blocks.len(), 1);
397 assert_eq!(blocks[0].content, "initial content");
398
399 std::fs::write(&md_path, "updated content").unwrap();
400 let blocks2 = load_instructions(dir.path(), &[], &[], false);
401 assert_eq!(blocks2.len(), 1);
402 assert_eq!(blocks2[0].content, "updated content");
403 }
404
405 #[test]
406 fn reload_returns_empty_when_file_deleted() {
407 let dir = tempfile::tempdir().unwrap();
408 let md_path = dir.path().join("zeph.md");
409 std::fs::write(&md_path, "content").unwrap();
410
411 let blocks = load_instructions(dir.path(), &[], &[], false);
412 assert_eq!(blocks.len(), 1);
413
414 std::fs::remove_file(&md_path).unwrap();
415 let blocks2 = load_instructions(dir.path(), &[], &[], false);
416 assert!(
417 blocks2.is_empty(),
418 "deleted file should not be loaded on reload"
419 );
420 }
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use std::fs;
427 use tempfile::TempDir;
428
429 fn make_file(dir: &Path, name: &str, content: &str) -> PathBuf {
430 let path = dir.join(name);
431 if let Some(parent) = path.parent() {
432 fs::create_dir_all(parent).unwrap();
433 }
434 fs::write(&path, content).unwrap();
435 path
436 }
437
438 #[test]
439 fn zeph_md_loaded_even_when_auto_detect_disabled() {
440 let dir = TempDir::new().unwrap();
441 make_file(dir.path(), "zeph.md", "some content");
442 let blocks = load_instructions(dir.path(), &[], &[], false);
443 assert_eq!(blocks.len(), 1);
444 assert_eq!(blocks[0].content, "some content");
445 }
446
447 #[test]
448 fn empty_when_no_auto_detect_and_no_explicit_and_no_zeph_md() {
449 let dir = TempDir::new().unwrap();
450 let blocks = load_instructions(dir.path(), &[], &[], false);
451 assert!(blocks.is_empty());
452 }
453
454 #[test]
455 fn finds_zeph_md_in_base_dir() {
456 let dir = TempDir::new().unwrap();
457 make_file(dir.path(), "zeph.md", "zeph instructions");
458 let blocks = load_instructions(dir.path(), &[], &[], true);
459 assert_eq!(blocks.len(), 1);
460 assert_eq!(blocks[0].content, "zeph instructions");
461 }
462
463 #[test]
464 fn finds_dot_zeph_zeph_md() {
465 let dir = TempDir::new().unwrap();
466 make_file(dir.path(), ".zeph/zeph.md", "nested zeph instructions");
467 let blocks = load_instructions(dir.path(), &[], &[], true);
468 assert_eq!(blocks.len(), 1);
469 assert_eq!(blocks[0].content, "nested zeph instructions");
470 }
471
472 #[test]
473 fn detection_paths_claude() {
474 let dir = TempDir::new().unwrap();
475 make_file(dir.path(), "CLAUDE.md", "# Claude");
476 make_file(dir.path(), ".claude/CLAUDE.md", "# Dot Claude");
477 make_file(dir.path(), ".claude/rules/a.md", "rule a");
478 make_file(dir.path(), ".claude/rules/b.md", "rule b");
479
480 let blocks = load_instructions(dir.path(), &[ProviderKind::Claude], &[], true);
481 let sources: Vec<_> = blocks
482 .iter()
483 .map(|b| b.source.file_name().unwrap().to_str().unwrap())
484 .collect();
485 assert!(sources.contains(&"CLAUDE.md"));
486 assert!(sources.contains(&"a.md"));
487 assert!(sources.contains(&"b.md"));
488 }
489
490 #[test]
491 fn detection_paths_openai() {
492 let dir = TempDir::new().unwrap();
493 make_file(dir.path(), "AGENTS.md", "# Agents");
494
495 let paths = detection_paths(ProviderKind::OpenAi, dir.path());
496 assert!(paths.iter().any(|p| p.file_name().unwrap() == "AGENTS.md"));
497 assert!(
498 paths
499 .iter()
500 .any(|p| p.file_name().unwrap() == "AGENTS.override.md")
501 );
502 }
503
504 #[test]
505 fn detection_paths_ollama_and_compatible_and_candle() {
506 let dir = TempDir::new().unwrap();
507 for kind in [
508 ProviderKind::Ollama,
509 ProviderKind::Compatible,
510 ProviderKind::Candle,
511 ] {
512 let paths = detection_paths(kind, dir.path());
513 assert_eq!(paths.len(), 1);
514 assert_eq!(paths[0].file_name().unwrap(), "AGENTS.md");
515 }
516 }
517
518 #[test]
519 fn deduplication_by_canonical_path() {
520 let dir = TempDir::new().unwrap();
521 make_file(dir.path(), "AGENTS.md", "content");
522
523 let blocks = load_instructions(
525 dir.path(),
526 &[ProviderKind::Ollama, ProviderKind::Compatible],
527 &[],
528 true,
529 );
530 let agents_count = blocks
531 .iter()
532 .filter(|b| b.source.file_name().unwrap() == "AGENTS.md")
533 .count();
534 assert_eq!(agents_count, 1);
535 }
536
537 #[test]
538 fn skips_files_exceeding_size_limit() {
539 let dir = TempDir::new().unwrap();
540 let path = dir.path().join("big.md");
541 let big = vec![b'x'; 513 * 1024];
543 fs::write(&path, &big).unwrap();
544 let blocks = load_instructions(dir.path(), &[], &[path], false);
545 assert!(blocks.is_empty());
546 }
547
548 #[test]
549 fn skips_empty_files() {
550 let dir = TempDir::new().unwrap();
551 make_file(dir.path(), "zeph.md", "");
552 let blocks = load_instructions(dir.path(), &[], &[], true);
553 assert!(blocks.is_empty());
554 }
555
556 #[test]
557 fn nonexistent_paths_are_silently_skipped() {
558 let dir = TempDir::new().unwrap();
559 let nonexistent = dir.path().join("does_not_exist.md");
560 let blocks = load_instructions(dir.path(), &[], &[nonexistent], false);
561 assert!(blocks.is_empty());
562 }
563
564 #[test]
565 fn explicit_relative_path_resolved_against_base_dir() {
566 let dir = TempDir::new().unwrap();
567 make_file(dir.path(), "custom.md", "custom content");
568 let blocks = load_instructions(dir.path(), &[], &[PathBuf::from("custom.md")], false);
569 assert_eq!(blocks.len(), 1);
570 assert_eq!(blocks[0].content, "custom content");
571 }
572
573 #[test]
574 fn invalid_utf8_file_is_skipped() {
575 let dir = TempDir::new().unwrap();
576 let path = dir.path().join("bad.md");
577 fs::write(&path, b"\xff\xfe invalid utf8 \x80\x81").unwrap();
579 let blocks = load_instructions(dir.path(), &[], &[path], false);
580 assert!(blocks.is_empty());
581 }
582
583 #[test]
584 fn multiple_providers_union_without_overlap() {
585 let dir = TempDir::new().unwrap();
586 make_file(dir.path(), "CLAUDE.md", "claude content");
587 make_file(dir.path(), "AGENTS.md", "agents content");
588
589 let blocks = load_instructions(
590 dir.path(),
591 &[ProviderKind::Claude, ProviderKind::OpenAi],
592 &[],
593 true,
594 );
595 let names: Vec<_> = blocks
596 .iter()
597 .map(|b| b.source.file_name().unwrap().to_str().unwrap())
598 .collect();
599 assert!(names.contains(&"CLAUDE.md"), "Claude file missing");
600 assert!(names.contains(&"AGENTS.md"), "OpenAI file missing");
601 }
602
603 #[test]
604 fn zeph_md_always_loaded_with_provider_auto_detect() {
605 let dir = TempDir::new().unwrap();
606 make_file(dir.path(), "zeph.md", "zeph rules");
607 let blocks = load_instructions(dir.path(), &[ProviderKind::OpenAi], &[], true);
609 assert_eq!(blocks.len(), 1);
610 assert_eq!(blocks[0].content, "zeph rules");
611 }
612
613 #[cfg(unix)]
614 #[test]
615 fn symlink_deduplication() {
616 use std::os::unix::fs::symlink;
617 let dir = TempDir::new().unwrap();
618 make_file(dir.path(), "CLAUDE.md", "claude content");
619 symlink(
620 dir.path().join("CLAUDE.md"),
621 dir.path().join("CLAUDE_link.md"),
622 )
623 .unwrap();
624
625 let blocks = load_instructions(
627 dir.path(),
628 &[ProviderKind::Claude],
629 &[PathBuf::from("CLAUDE_link.md")],
630 true,
631 );
632 let claude_count = blocks
633 .iter()
634 .filter(|b| b.content == "claude content")
635 .count();
636 assert_eq!(claude_count, 1, "symlink should be deduped with original");
637 }
638
639 #[cfg(unix)]
640 #[test]
641 fn symlink_escaping_project_root_is_rejected() {
642 use std::os::unix::fs::symlink;
643 let outside = TempDir::new().unwrap();
644 let inside = TempDir::new().unwrap();
645 make_file(outside.path(), "secret.md", "secret content");
646
647 let link = inside.path().join("evil.md");
649 symlink(outside.path().join("secret.md"), &link).unwrap();
650
651 let blocks = load_instructions(inside.path(), &[], &[link], false);
652 assert!(
653 blocks.is_empty(),
654 "file escaping project root must be rejected"
655 );
656 }
657
658 #[test]
659 fn file_with_null_bytes_is_skipped() {
660 let dir = TempDir::new().unwrap();
661 let path = dir.path().join("null.md");
662 fs::write(&path, b"content\x00more").unwrap();
663 let blocks = load_instructions(dir.path(), &[], &[path], false);
664 assert!(blocks.is_empty());
665 }
666}