use std::path::{Path, PathBuf};
use rahti_native::NativeError;
pub const MARKER: &str = "rahti.config.json";
pub const NATIVE_CONFIG: &str = "rahti.native.json";
#[derive(Debug, Clone)]
pub struct Project {
pub root: PathBuf,
pub package: String,
pub lib: String,
pub version: String,
}
impl Project {
pub fn discover(start: &Path) -> Result<Self, NativeError> {
let mut dir = Some(start);
while let Some(current) = dir {
if current.join(MARKER).is_file() {
return Self::at(current);
}
dir = current.parent();
}
Err(NativeError::at(
"project",
start,
format!(
"this is not a Rahti project — no {MARKER} here or in any directory above it.\n \
Run this from inside a project created by `cargo rahti new`."
),
))
}
pub fn at(root: &Path) -> Result<Self, NativeError> {
let manifest_path = root.join("Cargo.toml");
let manifest = std::fs::read_to_string(&manifest_path)
.map_err(|e| NativeError::io("project", &manifest_path, e))?;
let package = table_value(&manifest, "package", "name").ok_or_else(|| {
NativeError::at("project", &manifest_path, "this manifest names no package")
})?;
let version = table_value(&manifest, "package", "version")
.filter(|v| v != "true")
.unwrap_or_else(|| "0.1.0".to_string());
let lib =
table_value(&manifest, "lib", "name").unwrap_or_else(|| package.replace('-', "_"));
Ok(Project {
root: root.to_path_buf(),
package,
lib,
version,
})
}
pub fn native_dir(&self) -> PathBuf {
self.root.join("native")
}
pub fn native_config(&self) -> PathBuf {
self.root.join(NATIVE_CONFIG)
}
pub fn native_schema(&self) -> PathBuf {
self.root.join("rahti.native.schema.json")
}
pub fn has_shared_startup(&self) -> bool {
std::fs::read_to_string(self.root.join("src/lib.rs"))
.is_ok_and(|source| source.contains("pub async fn initialize_application"))
}
pub fn cookie_name(&self) -> Option<String> {
let env = std::fs::read_to_string(self.root.join(".env")).ok()?;
for line in env.lines() {
let line = line.trim();
if line.starts_with('#') {
continue;
}
let Some((name, value)) = line.split_once('=') else {
continue;
};
if name.trim() != "AUTH_COOKIE_NAME" {
continue;
}
let value = value.trim().trim_matches('"').trim_matches('\'').trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
None
}
}
fn table_value(manifest: &str, table: &str, key: &str) -> Option<String> {
let mut inside = false;
for line in manifest.lines() {
let line = line.split('#').next().unwrap_or("").trim();
if line.starts_with('[') {
inside = line == format!("[{table}]");
continue;
}
if !inside {
continue;
}
let Some((name, value)) = line.split_once('=') else {
continue;
};
if name.trim() != key {
continue;
}
let value = value.trim();
return Some(
match value.strip_prefix('"').and_then(|v| v.strip_suffix('"')) {
Some(quoted) => quoted.to_string(),
None => value.to_string(),
},
);
}
None
}
#[cfg(test)]
#[path = "tests/project.rs"]
mod tests;