Skip to main content

zeph_core/
project.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::fmt::Write;
5use std::path::{Path, PathBuf};
6
7const PROJECT_CONFIG_FILES: &[&str] = &["ZEPH.md", ".zeph/config.md"];
8
9/// Walk up from `start` to filesystem root, collecting all ZEPH.md files.
10/// Returns paths ordered from most general (ancestor) to most specific (cwd).
11#[must_use]
12pub fn discover_project_configs(start: &Path) -> Vec<PathBuf> {
13    let mut configs = Vec::new();
14    let mut current = start.to_path_buf();
15
16    loop {
17        for filename in PROJECT_CONFIG_FILES {
18            let candidate = current.join(filename);
19            if candidate.is_file() {
20                configs.push(candidate);
21            }
22        }
23        if !current.pop() {
24            break;
25        }
26    }
27
28    configs.reverse();
29    configs
30}
31
32/// Load and concatenate project configs into a prompt section.
33#[must_use]
34pub fn load_project_context(configs: &[PathBuf]) -> String {
35    if configs.is_empty() {
36        return String::new();
37    }
38
39    let mut out = String::from("<project_context>\n");
40    for path in configs {
41        if let Ok(content) = std::fs::read_to_string(path) {
42            let source = path.display();
43            let _ = write!(
44                out,
45                "  <config source=\"{source}\">\n{content}\n  </config>\n"
46            );
47        }
48    }
49    out.push_str("</project_context>");
50    out
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn discover_project_configs_empty_dir() {
59        let dir = tempfile::tempdir().unwrap();
60        let configs = discover_project_configs(dir.path());
61        assert!(configs.is_empty());
62    }
63
64    #[test]
65    fn discover_project_configs_finds_zeph_md() {
66        let dir = tempfile::tempdir().unwrap();
67        std::fs::write(dir.path().join("ZEPH.md"), "# Project").unwrap();
68        let configs = discover_project_configs(dir.path());
69        assert_eq!(configs.len(), 1);
70        assert!(configs[0].ends_with("ZEPH.md"));
71    }
72
73    #[test]
74    fn discover_project_configs_walks_up() {
75        let dir = tempfile::tempdir().unwrap();
76        let child = dir.path().join("sub");
77        std::fs::create_dir(&child).unwrap();
78        std::fs::write(dir.path().join("ZEPH.md"), "# Parent").unwrap();
79        std::fs::write(child.join("ZEPH.md"), "# Child").unwrap();
80
81        let configs = discover_project_configs(&child);
82        assert!(configs.len() >= 2);
83        // Parent should come before child (reversed order)
84        let parent_idx = configs
85            .iter()
86            .position(|p| p.parent().unwrap() == dir.path())
87            .unwrap();
88        let child_idx = configs
89            .iter()
90            .position(|p| p.parent().unwrap() == child)
91            .unwrap();
92        assert!(parent_idx < child_idx);
93    }
94
95    #[test]
96    fn load_project_context_empty() {
97        let result = load_project_context(&[]);
98        assert!(result.is_empty());
99    }
100
101    #[test]
102    fn load_project_context_concatenates() {
103        let dir = tempfile::tempdir().unwrap();
104        let f1 = dir.path().join("ZEPH.md");
105        let f2 = dir.path().join("other.md");
106        std::fs::write(&f1, "config 1").unwrap();
107        std::fs::write(&f2, "config 2").unwrap();
108
109        let result = load_project_context(&[f1, f2]);
110        assert!(result.starts_with("<project_context>"));
111        assert!(result.ends_with("</project_context>"));
112        assert!(result.contains("config 1"));
113        assert!(result.contains("config 2"));
114        assert!(result.contains("<config source="));
115    }
116}