use color_eyre::{eyre::bail, Result};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Network {
Local,
Testnet,
Mainnet,
}
impl std::fmt::Display for Network {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Network::Local => write!(f, "local"),
Network::Testnet => write!(f, "testnet"),
Network::Mainnet => write!(f, "mainnet"),
}
}
}
impl std::str::FromStr for Network {
type Err = String;
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"local" => Ok(Network::Local),
"testnet" => Ok(Network::Testnet),
"mainnet" => Ok(Network::Mainnet),
_ => Err(format!("Unknown network: {}", s)),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Contract {
pub name: String,
pub address: Option<String>,
pub wasm_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ProjectSource {
#[default]
Manifest,
Inferred,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub name: String,
pub version: String,
pub default_network: Network,
pub contracts: Vec<Contract>,
#[serde(skip)]
pub source: ProjectSource,
}
#[derive(Debug, Clone)]
pub struct Discovery {
pub dir: PathBuf,
pub project: Project,
}
impl Project {
pub fn new(name: &str) -> Self {
Self {
name: name.to_string(),
version: "0.1.0".to_string(),
default_network: Network::Testnet,
contracts: Vec::new(),
source: ProjectSource::Manifest,
}
}
pub fn is_inferred(&self) -> bool {
self.source == ProjectSource::Inferred
}
pub async fn save(&self, path: &Path) -> Result<()> {
if let Some(parent) = path.parent() {
tokio::fs::create_dir_all(parent).await?;
}
let toml = toml::to_string_pretty(self)?;
tokio::fs::write(path, toml).await?;
Ok(())
}
pub async fn load(path: &Path) -> Result<Self> {
let toml = tokio::fs::read_to_string(path).await?;
let project: Self = toml::from_str(&toml)?;
Ok(project)
}
pub async fn find_project_dir(start: &Path) -> Result<PathBuf> {
let mut current = start.to_path_buf();
loop {
let project_file = current.join(".procyon").join("project.toml");
if tokio::fs::try_exists(&project_file).await.unwrap_or(false) {
return Ok(current);
}
if !current.pop() {
bail!("No .procyon/project.toml found in parent directories");
}
}
}
pub async fn discover(start: &Path) -> Option<Discovery> {
let mut current = start.to_path_buf();
loop {
let manifest = current.join(".procyon").join("project.toml");
if tokio::fs::try_exists(&manifest).await.unwrap_or(false) {
if let Ok(project) = Self::load(&manifest).await {
return Some(Discovery {
dir: current,
project,
});
}
}
if let Some(project) = infer_at(¤t).await {
return Some(Discovery {
dir: current,
project,
});
}
if !current.pop() {
return None;
}
}
}
}
async fn infer_at(dir: &Path) -> Option<Project> {
let caatinga = tokio::fs::try_exists(dir.join("caatinga.config.ts"))
.await
.unwrap_or(false);
let contracts = scan_contracts(dir).await;
let root_contract = is_soroban_manifest(&dir.join("Cargo.toml")).await;
if !caatinga && contracts.is_empty() && !root_contract {
return None;
}
let (pkg_name, pkg_version) = package_json_identity(dir).await;
let name = pkg_name
.or(cargo_package_name(&dir.join("Cargo.toml")).await)
.or_else(|| {
dir.file_name()
.map(|n| n.to_string_lossy().into_owned())
.filter(|n| !n.is_empty())
})?;
let mut contracts = contracts;
if root_contract {
if let Some(root) = cargo_package_name(&dir.join("Cargo.toml")).await {
contracts.push(Contract {
name: root,
address: None,
wasm_path: None,
});
}
}
apply_artifact_addresses(dir, &mut contracts).await;
Some(Project {
name,
version: pkg_version.unwrap_or_else(|| "0.1.0".to_string()),
default_network: Network::Testnet,
contracts,
source: ProjectSource::Inferred,
})
}
async fn contracts_dir(dir: &Path) -> PathBuf {
let template = tokio::fs::read_to_string(dir.join("caatinga.template.json"))
.await
.ok()
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
.and_then(|json| {
json.get("contracts")?
.get("path")?
.as_str()
.map(str::to_string)
});
dir.join(template.as_deref().unwrap_or("contracts"))
}
async fn scan_contracts(dir: &Path) -> Vec<Contract> {
let root = contracts_dir(dir).await;
let Ok(mut entries) = tokio::fs::read_dir(&root).await else {
return Vec::new();
};
let mut found = Vec::new();
while let Ok(Some(entry)) = entries.next_entry().await {
let manifest = entry.path().join("Cargo.toml");
if !is_soroban_manifest(&manifest).await {
continue;
}
let name = cargo_package_name(&manifest)
.await
.unwrap_or_else(|| entry.file_name().to_string_lossy().into_owned());
found.push(Contract {
name,
address: None,
wasm_path: None,
});
}
found.sort_by(|a, b| a.name.cmp(&b.name));
found
}
async fn is_soroban_manifest(path: &Path) -> bool {
match tokio::fs::read_to_string(path).await {
Ok(raw) => raw.contains("soroban-sdk"),
Err(_) => false,
}
}
async fn cargo_package_name(path: &Path) -> Option<String> {
let raw = tokio::fs::read_to_string(path).await.ok()?;
let parsed: toml::Value = toml::from_str(&raw).ok()?;
parsed
.get("package")?
.get("name")?
.as_str()
.map(str::to_string)
}
async fn package_json_identity(dir: &Path) -> (Option<String>, Option<String>) {
let Ok(raw) = tokio::fs::read_to_string(dir.join("package.json")).await else {
return (None, None);
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return (None, None);
};
let field = |key: &str| {
json.get(key)
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(str::to_string)
};
(field("name"), field("version"))
}
async fn apply_artifact_addresses(dir: &Path, contracts: &mut [Contract]) {
if contracts.is_empty() {
return;
}
let Ok(raw) = tokio::fs::read_to_string(dir.join("caatinga.artifacts.json")).await else {
return;
};
let Ok(json) = serde_json::from_str::<serde_json::Value>(&raw) else {
return;
};
let Some(networks) = json.get("networks").and_then(|n| n.as_object()) else {
return;
};
for contract in contracts.iter_mut() {
for network in networks.values() {
let Some(entry) = network
.get("contracts")
.and_then(|c| c.as_object())
.and_then(|c| c.get(&contract.name))
else {
continue;
};
let address = match entry {
serde_json::Value::String(id) => Some(id.clone()),
serde_json::Value::Object(_) => ["id", "contractId", "address"]
.iter()
.find_map(|key| entry.get(*key).and_then(|v| v.as_str()))
.map(str::to_string),
_ => None,
};
if address.is_some() {
contract.address = address;
break;
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
#[test]
fn deserializes_lowercase_network_from_project_file() {
let toml_str = r#"
name = "demo"
version = "0.1.0"
default_network = "testnet"
contracts = []
accounts = []
"#;
let project: Project = toml::from_str(toml_str).unwrap();
assert_eq!(project.default_network.to_string(), "testnet");
}
#[test]
fn a_project_file_written_before_accounts_moved_out_still_loads() {
let legacy = r#"
name = "demo"
version = "0.1.0"
default_network = "testnet"
contracts = []
[[accounts]]
name = "alice"
address = "GALICE"
network = "testnet"
"#;
let project: Project = toml::from_str(legacy).expect("legacy file must still parse");
assert_eq!(project.name, "demo");
}
#[test]
fn accounts_are_not_serialized_into_the_project_file() {
let written = toml::to_string(&Project::new("demo")).unwrap();
assert!(
!written.contains("accounts"),
"accounts belong to AccountStore, not project.toml: {}",
written
);
}
#[test]
fn serde_agrees_with_display() {
for network in [Network::Local, Network::Testnet, Network::Mainnet] {
let project = Project {
default_network: network.clone(),
..Project::new("demo")
};
let written = toml::to_string(&project).unwrap();
assert!(
written.contains(&format!("default_network = \"{}\"", network)),
"Display spelling of {:?} is not what serde wrote: {}",
network,
written
);
}
}
#[test]
fn from_str_matches_serde() {
for spelling in ["local", "testnet", "mainnet"] {
let toml_str = format!(
"name = \"d\"\nversion = \"0.1.0\"\ndefault_network = \"{}\"\ncontracts = []\naccounts = []\n",
spelling
);
let project: Project = toml::from_str(&toml_str).unwrap();
assert_eq!(
Network::from_str(spelling).unwrap().to_string(),
project.default_network.to_string()
);
}
}
async fn write(path: &Path, body: &str) {
tokio::fs::create_dir_all(path.parent().unwrap())
.await
.unwrap();
tokio::fs::write(path, body).await.unwrap();
}
async fn soroban_contract(dir: &Path, crate_name: &str) {
write(
&dir.join("Cargo.toml"),
&format!(
"[package]\nname = \"{}\"\nversion = \"0.1.0\"\n\n[dependencies]\nsoroban-sdk = \"22.0.1\"\n",
crate_name
),
)
.await;
}
#[tokio::test]
async fn finds_a_caatinga_project_that_never_ran_project_init() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
write(
&temp.path().join("package.json"),
r#"{"name":"my-app","version":"0.2.0"}"#,
)
.await;
soroban_contract(&temp.path().join("contracts").join("counter"), "counter").await;
let found = Project::discover(temp.path())
.await
.expect("a Caatinga project with a Soroban contract is a project");
assert_eq!(found.project.name, "my-app");
assert_eq!(found.project.version, "0.2.0");
assert!(found.project.is_inferred());
assert_eq!(found.project.contracts.len(), 1);
assert_eq!(found.project.contracts[0].name, "counter");
}
#[tokio::test]
async fn a_plain_rust_checkout_is_not_a_stellar_project() {
let temp = tempfile::tempdir().unwrap();
write(
&temp.path().join("Cargo.toml"),
"[package]\nname = \"cli\"\nversion = \"1.0.0\"\n\n[dependencies]\nserde = \"1\"\n",
)
.await;
assert!(
Project::discover(temp.path()).await.is_none(),
"soroban-sdk is the signal; any Cargo.toml would claim every repo on the machine"
);
}
#[tokio::test]
async fn a_bare_contract_crate_is_a_project_on_its_own() {
let temp = tempfile::tempdir().unwrap();
soroban_contract(temp.path(), "token").await;
let found = Project::discover(temp.path()).await.expect("soroban crate");
assert_eq!(found.project.name, "token");
assert_eq!(found.project.contracts[0].name, "token");
}
#[tokio::test]
async fn the_manifest_wins_wherever_it_exists() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
write(
&temp.path().join("package.json"),
r#"{"name":"from-package-json"}"#,
)
.await;
Project::new("from-manifest")
.save(&temp.path().join(".procyon").join("project.toml"))
.await
.unwrap();
let found = Project::discover(temp.path()).await.unwrap();
assert_eq!(found.project.name, "from-manifest");
assert!(
!found.project.is_inferred(),
"what the user configured must not be second-guessed by the filesystem"
);
}
#[tokio::test]
async fn discovery_walks_up_from_a_subdirectory() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
let nested = temp.path().join("src").join("components");
tokio::fs::create_dir_all(&nested).await.unwrap();
let found = Project::discover(&nested)
.await
.expect("working in src/ is still working in the project");
assert_eq!(found.dir, temp.path());
}
#[tokio::test]
async fn deployed_addresses_come_from_the_caatinga_artifacts() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
soroban_contract(&temp.path().join("contracts").join("counter"), "counter").await;
write(
&temp.path().join("caatinga.artifacts.json"),
r#"{"networks":{"testnet":{"contracts":{"counter":{"id":"CDLZ"}}}},"version":1}"#,
)
.await;
let found = Project::discover(temp.path()).await.unwrap();
assert_eq!(found.project.contracts[0].address.as_deref(), Some("CDLZ"));
}
#[tokio::test]
async fn an_undeployed_contract_has_no_address_rather_than_a_wrong_one() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
soroban_contract(&temp.path().join("contracts").join("counter"), "counter").await;
write(
&temp.path().join("caatinga.artifacts.json"),
r#"{"project":"my-app","networks":{"testnet":{"contracts":{},"dependencyGraph":{}}},"version":1}"#,
)
.await;
let found = Project::discover(temp.path()).await.unwrap();
assert!(found.project.contracts[0].address.is_none());
}
#[tokio::test]
async fn the_contracts_directory_follows_the_template() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
write(
&temp.path().join("caatinga.template.json"),
r#"{"contracts":{"path":"soroban","default":"counter"}}"#,
)
.await;
soroban_contract(&temp.path().join("soroban").join("counter"), "counter").await;
let found = Project::discover(temp.path()).await.unwrap();
assert_eq!(found.project.contracts.len(), 1);
}
#[tokio::test]
async fn the_crate_name_beats_the_directory_name() {
let temp = tempfile::tempdir().unwrap();
soroban_contract(
&temp.path().join("contracts").join("counter-contract"),
"counter",
)
.await;
let found = Project::discover(temp.path()).await.unwrap();
assert_eq!(found.project.contracts[0].name, "counter");
}
#[tokio::test]
async fn a_corrupt_manifest_falls_back_to_the_repository() {
let temp = tempfile::tempdir().unwrap();
write(&temp.path().join("caatinga.config.ts"), "export default {}").await;
write(&temp.path().join("package.json"), r#"{"name":"my-app"}"#).await;
write(
&temp.path().join(".procyon").join("project.toml"),
"this is not toml {{{",
)
.await;
let found = Project::discover(temp.path())
.await
.expect("a broken manifest must not hide a project that is plainly there");
assert_eq!(found.project.name, "my-app");
assert!(found.project.is_inferred());
}
#[test]
fn discovery_does_not_leak_into_the_project_file() {
let inferred = Project {
source: ProjectSource::Inferred,
..Project::new("demo")
};
let written = toml::to_string(&inferred).unwrap();
assert!(
!written.contains("source"),
"how we found the project is not part of it: {}",
written
);
}
#[test]
fn rejects_unknown_network() {
let toml_str = "name = \"d\"\nversion = \"0.1.0\"\ndefault_network = \"futurenet\"\ncontracts = []\naccounts = []\n";
assert!(toml::from_str::<Project>(toml_str).is_err());
}
}