Skip to main content

systemprompt_cloud/paths/
project.rs

1//! Typed project- and profile-relative paths and the [`ProjectContext`] that
2//! resolves them against a discovered project root.
3//!
4//! [`ProjectPath`] and [`ProfilePath`] enumerate the well-known files and
5//! directories so resolution stays a single source of truth; root discovery
6//! walks upward looking for a `.systemprompt` directory.
7
8use std::path::{Path, PathBuf};
9
10use crate::constants::{dir_names, paths};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
13pub enum ProjectPath {
14    Root,
15    ProfilesDir,
16    DockerDir,
17    StorageDir,
18    SessionsDir,
19    Dockerfile,
20    LocalCredentials,
21    LocalTenants,
22    LocalSession,
23}
24
25impl ProjectPath {
26    #[must_use]
27    pub const fn segments(&self) -> &'static [&'static str] {
28        match self {
29            Self::Root => &[paths::ROOT_DIR],
30            Self::ProfilesDir => &[paths::ROOT_DIR, paths::PROFILES_DIR],
31            Self::DockerDir => &[paths::ROOT_DIR, paths::DOCKER_DIR],
32            Self::StorageDir => &[paths::STORAGE_DIR],
33            Self::SessionsDir => &[paths::ROOT_DIR, dir_names::SESSIONS],
34            Self::Dockerfile => &[paths::ROOT_DIR, paths::DOCKERFILE],
35            Self::LocalCredentials => &[paths::ROOT_DIR, paths::CREDENTIALS_FILE],
36            Self::LocalTenants => &[paths::ROOT_DIR, paths::TENANTS_FILE],
37            Self::LocalSession => &[paths::ROOT_DIR, paths::SESSION_FILE],
38        }
39    }
40
41    #[must_use]
42    pub const fn is_dir(&self) -> bool {
43        matches!(
44            self,
45            Self::Root | Self::ProfilesDir | Self::DockerDir | Self::StorageDir | Self::SessionsDir
46        )
47    }
48
49    #[must_use]
50    pub fn resolve(&self, project_root: &Path) -> PathBuf {
51        let mut path = project_root.to_path_buf();
52        for segment in self.segments() {
53            path.push(segment);
54        }
55        path
56    }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
60pub enum ProfilePath {
61    Config,
62    Secrets,
63    DockerDir,
64    Dockerfile,
65    Entrypoint,
66    Dockerignore,
67    Compose,
68}
69
70impl ProfilePath {
71    #[must_use]
72    pub const fn filename(&self) -> &'static str {
73        match self {
74            Self::Config => paths::PROFILE_CONFIG,
75            Self::Secrets => paths::PROFILE_SECRETS,
76            Self::DockerDir => paths::PROFILE_DOCKER_DIR,
77            Self::Dockerfile => paths::DOCKERFILE,
78            Self::Entrypoint => paths::ENTRYPOINT,
79            Self::Dockerignore => paths::DOCKERIGNORE,
80            Self::Compose => paths::COMPOSE_FILE,
81        }
82    }
83
84    #[must_use]
85    pub const fn is_docker_file(&self) -> bool {
86        matches!(
87            self,
88            Self::Dockerfile | Self::Entrypoint | Self::Dockerignore | Self::Compose
89        )
90    }
91
92    #[must_use]
93    pub fn resolve(&self, profile_dir: &Path) -> PathBuf {
94        match self {
95            Self::Dockerfile | Self::Entrypoint | Self::Dockerignore | Self::Compose => profile_dir
96                .join(paths::PROFILE_DOCKER_DIR)
97                .join(self.filename()),
98            _ => profile_dir.join(self.filename()),
99        }
100    }
101}
102
103fn is_valid_project_root(path: &Path) -> bool {
104    if !path.join(paths::ROOT_DIR).is_dir() {
105        return false;
106    }
107    path.join("Cargo.toml").exists()
108        || path.join("services").is_dir()
109        || path.join("storage").is_dir()
110}
111
112#[derive(Debug, Clone)]
113pub struct ProjectContext {
114    root: PathBuf,
115}
116
117impl ProjectContext {
118    #[must_use]
119    pub const fn new(root: PathBuf) -> Self {
120        Self { root }
121    }
122
123    #[must_use]
124    pub fn discover() -> Self {
125        let cwd = match std::env::current_dir() {
126            Ok(dir) => dir,
127            Err(e) => {
128                tracing::debug!(error = %e, "Failed to get current directory, using '.'");
129                PathBuf::from(".")
130            },
131        };
132        Self::discover_from(&cwd)
133    }
134
135    #[must_use]
136    pub fn discover_from(start: &Path) -> Self {
137        let mut current = start.to_path_buf();
138        loop {
139            if is_valid_project_root(&current) {
140                return Self::new(current);
141            }
142            if !current.pop() {
143                break;
144            }
145        }
146        Self::new(start.to_path_buf())
147    }
148
149    #[must_use]
150    pub fn root(&self) -> &Path {
151        &self.root
152    }
153
154    #[must_use]
155    pub fn resolve(&self, path: ProjectPath) -> PathBuf {
156        path.resolve(&self.root)
157    }
158
159    #[must_use]
160    pub fn systemprompt_dir(&self) -> PathBuf {
161        self.resolve(ProjectPath::Root)
162    }
163
164    #[must_use]
165    pub fn profiles_dir(&self) -> PathBuf {
166        self.resolve(ProjectPath::ProfilesDir)
167    }
168
169    #[must_use]
170    pub fn profile_dir(&self, name: &str) -> PathBuf {
171        self.profiles_dir().join(name)
172    }
173
174    #[must_use]
175    pub fn profile_path(&self, name: &str, path: ProfilePath) -> PathBuf {
176        path.resolve(&self.profile_dir(name))
177    }
178
179    #[must_use]
180    pub fn profile_docker_dir(&self, name: &str) -> PathBuf {
181        self.profile_path(name, ProfilePath::DockerDir)
182    }
183
184    #[must_use]
185    pub fn profile_dockerfile(&self, name: &str) -> PathBuf {
186        self.profile_path(name, ProfilePath::Dockerfile)
187    }
188
189    #[must_use]
190    pub fn profile_entrypoint(&self, name: &str) -> PathBuf {
191        self.profile_path(name, ProfilePath::Entrypoint)
192    }
193
194    #[must_use]
195    pub fn profile_dockerignore(&self, name: &str) -> PathBuf {
196        self.profile_path(name, ProfilePath::Dockerignore)
197    }
198
199    #[must_use]
200    pub fn profile_compose(&self, name: &str) -> PathBuf {
201        self.profile_path(name, ProfilePath::Compose)
202    }
203
204    #[must_use]
205    pub fn docker_dir(&self) -> PathBuf {
206        self.resolve(ProjectPath::DockerDir)
207    }
208
209    #[must_use]
210    pub fn storage_dir(&self) -> PathBuf {
211        self.resolve(ProjectPath::StorageDir)
212    }
213
214    #[must_use]
215    pub fn dockerfile(&self) -> PathBuf {
216        self.resolve(ProjectPath::Dockerfile)
217    }
218
219    #[must_use]
220    pub fn local_credentials(&self) -> PathBuf {
221        self.resolve(ProjectPath::LocalCredentials)
222    }
223
224    #[must_use]
225    pub fn local_tenants(&self) -> PathBuf {
226        self.resolve(ProjectPath::LocalTenants)
227    }
228
229    #[must_use]
230    pub fn sessions_dir(&self) -> PathBuf {
231        self.resolve(ProjectPath::SessionsDir)
232    }
233
234    #[must_use]
235    pub fn exists(&self, path: ProjectPath) -> bool {
236        self.resolve(path).exists()
237    }
238
239    #[must_use]
240    pub fn profile_exists(&self, name: &str) -> bool {
241        self.profile_dir(name).exists()
242    }
243}