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