Skip to main content

zeph_core/
instructions.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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    /// Start watching directories for instruction file (.md) changes.
27    ///
28    /// Sends `InstructionEvent::Changed` on any `.md` filesystem change (debounced 500ms).
29    ///
30    /// # Errors
31    ///
32    /// Returns an error if the filesystem watcher cannot be initialized.
33    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
90/// Parameters needed to re-run `load_instructions()` on hot-reload.
91pub 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
98/// Maximum size of a single instruction file. Files exceeding this limit are skipped.
99const MAX_FILE_SIZE: u64 = 256 * 1024; // 256 KiB
100
101/// A loaded instruction block from a single file.
102#[derive(Debug, Clone)]
103pub struct InstructionBlock {
104    /// Absolute path of the source file.
105    pub source: PathBuf,
106    /// UTF-8 text content of the file.
107    pub content: String,
108}
109
110/// Load instruction blocks from provider-specific and explicit files.
111///
112/// `base_dir` is resolved as the process working directory at startup via
113/// `std::env::current_dir()`. This matches the directory from which the user
114/// launches `zeph` and is therefore the most natural project root for file
115/// discovery. Non-git projects are fully supported; git root is not used.
116///
117/// Candidate paths are collected in this order:
118/// 1. Always: `base_dir/zeph.md` and `base_dir/.zeph/zeph.md`.
119/// 2. If `auto_detect`, per-provider paths from `detection_paths()` for each kind.
120/// 3. `explicit_files` as provided (trusted — user controls config.toml).
121///
122/// Deduplication uses `fs::canonicalize`. Paths that do not exist are silently
123/// skipped; canonicalize fails on nonexistent paths, so they cannot be deduped
124/// via symlinks against existing paths — this is an acceptable edge case documented here.
125pub 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    // zeph.md is always checked regardless of provider or auto_detect setting.
142    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    // Explicit files are trusted (user controls config). Resolve relative to base_dir.
152    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    // Deduplicate by canonical path. Only existing paths can be canonicalized.
161    let mut seen: HashSet<PathBuf> = HashSet::new();
162    let mut result: Vec<InstructionBlock> = Vec::new();
163
164    for path in candidates {
165        // Canonicalize first to resolve symlinks before opening — eliminates TOCTOU race.
166        // Nonexistent or unreadable paths are silently skipped.
167        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            // Already loaded this path via a different candidate or symlink.
178            continue;
179        }
180
181        // Open the canonical path after boundary check — no TOCTOU window for symlink swap.
182        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
237/// Returns candidate file paths for a given provider.
238///
239/// Uses an exhaustive match — adding a new `ProviderKind` variant will cause
240/// a compile error here, forcing the developer to update the detection table.
241fn 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            // Collect .claude/rules/*.md sorted by name for deterministic order.
249            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
276/// Async wrapper around [`load_instructions`] that offloads filesystem I/O to the tokio
277/// blocking thread pool.
278///
279/// Returns an empty `Vec` and logs an error if the blocking task panics.
280pub 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        // Both Ollama and Compatible resolve to AGENTS.md — should appear once.
524        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        // Write slightly more than 512 KB.
542        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        // Write bytes that are not valid UTF-8.
578        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        // OpenAI provider has no AGENTS.md present, only zeph.md.
608        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        // Load the original and the symlink — should appear only once after dedup.
626        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        // Create a symlink inside the project dir pointing outside.
648        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}