use crate::model::{Env, Meta, Request};
use anyhow::{Context, Result, bail};
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
pub const META_FILE: &str = "turly.yaml";
#[derive(Clone, Debug)]
pub enum Node {
Dir {
name: String,
path: PathBuf,
children: Vec<Node>,
},
Req {
path: PathBuf,
req: Box<Request>,
},
}
impl Node {
pub fn name(&self) -> &str {
match self {
Node::Dir { name, .. } => name,
Node::Req { req, .. } => &req.name,
}
}
}
#[derive(Clone, Debug)]
pub struct Workspace {
pub root: PathBuf,
pub meta: Meta,
}
pub fn slug(s: &str) -> String {
let mut out = String::new();
for c in s.chars() {
if c.is_ascii_alphanumeric() {
out.push(c.to_ascii_lowercase());
} else if !out.ends_with('-') && !out.is_empty() {
out.push('-');
}
}
let out = out.trim_end_matches('-').to_string();
if out.is_empty() {
"untitled".into()
} else {
out
}
}
fn read_yaml<T: serde::de::DeserializeOwned>(p: &Path) -> Result<T> {
let s = fs::read_to_string(p).with_context(|| format!("reading {}", p.display()))?;
serde_yaml_ng::from_str(&s).with_context(|| format!("parsing {}", p.display()))
}
fn write_yaml<T: serde::Serialize>(p: &Path, v: &T) -> Result<()> {
if let Some(dir) = p.parent() {
fs::create_dir_all(dir)?;
}
fs::write(p, serde_yaml_ng::to_string(v)?).with_context(|| format!("writing {}", p.display()))
}
impl Workspace {
pub fn is_workspace(root: &Path) -> bool {
root.join(META_FILE).is_file()
}
pub fn init(root: &Path, name: &str) -> Result<Workspace> {
if Self::is_workspace(root) {
bail!("{} is already a Turly workspace", root.display());
}
fs::create_dir_all(root.join("requests"))?;
fs::create_dir_all(root.join("environments"))?;
fs::create_dir_all(root.join(".turly"))?;
let gi = root.join(".gitignore");
let mut existing = fs::read_to_string(&gi).unwrap_or_default();
if !existing.lines().any(|l| l.trim() == ".turly/") {
if !existing.is_empty() && !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(".turly/\n");
fs::write(&gi, existing)?;
}
let meta = Meta {
name: name.into(),
timeout_secs: 30,
active_env: None,
};
write_yaml(&root.join(META_FILE), &meta)?;
let mut ws = Workspace {
root: root.to_path_buf(),
meta,
};
if ws.envs()?.is_empty() {
let mut env = Env::default();
env.vars
.insert("base_url".into(), "http://localhost:8080".into());
ws.save_env("default", &env)?;
ws.meta.active_env = Some("default".into());
ws.save_meta()?;
}
Ok(ws)
}
pub fn open(root: &Path) -> Result<Workspace> {
let meta = read_yaml(&root.join(META_FILE))?;
Ok(Workspace {
root: root.to_path_buf(),
meta,
})
}
pub fn save_meta(&self) -> Result<()> {
write_yaml(&self.root.join(META_FILE), &self.meta)
}
pub fn requests_dir(&self) -> PathBuf {
self.root.join("requests")
}
pub fn tree(&self) -> Result<Vec<Node>> {
scan(&self.requests_dir())
}
pub fn save_request(&self, path: &Path, req: &Request) -> Result<()> {
write_yaml(path, req)
}
pub fn new_request_path(&self, dir: &Path, name: &str) -> PathBuf {
let base = slug(name);
let mut p = dir.join(format!("{base}.yaml"));
let mut n = 2;
while p.exists() {
p = dir.join(format!("{base}-{n}.yaml"));
n += 1;
}
p
}
pub fn envs(&self) -> Result<Vec<String>> {
let mut out = vec![];
if let Ok(rd) = fs::read_dir(self.root.join("environments")) {
for e in rd.flatten() {
let p = e.path();
if p.extension().is_some_and(|x| x == "yaml") {
out.push(p.file_stem().unwrap().to_string_lossy().into_owned());
}
}
}
out.sort();
Ok(out)
}
fn env_path(&self, name: &str) -> PathBuf {
self.root.join("environments").join(format!("{name}.yaml"))
}
fn secrets_path(&self) -> PathBuf {
self.root.join(".turly").join("secrets.yaml")
}
pub fn load_env(&self, name: &str) -> Result<Env> {
read_yaml(&self.env_path(name))
}
pub fn save_env(&self, name: &str, env: &Env) -> Result<()> {
write_yaml(&self.env_path(name), env)
}
fn load_secrets(&self) -> BTreeMap<String, BTreeMap<String, String>> {
read_yaml(&self.secrets_path()).unwrap_or_default()
}
pub fn resolved_vars(&self, env: Option<&str>) -> BTreeMap<String, String> {
let Some(name) = env else {
return BTreeMap::new();
};
let mut vars = self.load_env(name).map(|e| e.vars).unwrap_or_default();
if let Some(s) = self.load_secrets().remove(name) {
vars.extend(s);
}
vars
}
pub fn set_var(&self, env: &str, key: &str, value: &str, secret: bool) -> Result<()> {
let mut e = self.load_env(env)?;
if secret {
if !e.secrets.iter().any(|k| k == key) {
e.secrets.push(key.into());
}
e.vars.remove(key);
let mut all = self.load_secrets();
all.entry(env.into())
.or_default()
.insert(key.into(), value.into());
write_yaml(&self.secrets_path(), &all)?;
} else {
e.vars.insert(key.into(), value.into());
}
self.save_env(env, &e)
}
pub fn vars_text(&self, env: &str) -> Result<String> {
let e = self.load_env(env)?;
let mut sec = self.load_secrets().remove(env).unwrap_or_default();
let mut out: Vec<String> = e.vars.iter().map(|(k, v)| format!("{k}={v}")).collect();
for k in &e.secrets {
out.push(format!("secret {k}={}", sec.remove(k).unwrap_or_default()));
}
Ok(out.join("\n"))
}
pub fn save_vars_text(&self, env: &str, text: &str) -> Result<()> {
let mut e = self.load_env(env)?;
let (mut vars, mut secrets) = (BTreeMap::new(), BTreeMap::new());
for line in text.lines().map(str::trim) {
if line.is_empty() || line.starts_with('#') {
continue;
}
let (secret, kv) = match line.strip_prefix("secret ") {
Some(r) => (true, r),
None => (false, line),
};
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
let (k, v) = (k.trim().to_string(), v.trim().to_string());
if k.is_empty() {
continue;
}
if secret {
secrets.insert(k, v);
} else {
vars.insert(k, v);
}
}
e.vars = vars;
e.secrets = secrets.keys().cloned().collect();
self.save_env(env, &e)?;
let mut all = self.load_secrets();
all.insert(env.into(), secrets);
write_yaml(&self.secrets_path(), &all)
}
pub fn history_path(&self) -> PathBuf {
self.root.join(".turly").join("history.jsonl")
}
pub fn push_history(&self, line: &serde_json::Value) {
use std::io::Write;
if let Ok(mut f) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(self.history_path())
{
let _ = writeln!(f, "{line}");
}
}
pub fn history(&self, limit: usize) -> Vec<serde_json::Value> {
let s = fs::read_to_string(self.history_path()).unwrap_or_default();
let mut v: Vec<_> = s
.lines()
.filter_map(|l| serde_json::from_str(l).ok())
.collect();
v.reverse();
v.truncate(limit);
v
}
}
fn scan(dir: &Path) -> Result<Vec<Node>> {
let mut nodes: Vec<(Option<u32>, bool, Node)> = vec![];
let Ok(rd) = fs::read_dir(dir) else {
return Ok(vec![]);
};
for e in rd.flatten() {
let p = e.path();
let fname = e.file_name().to_string_lossy().into_owned();
if fname.starts_with('.') {
continue;
}
if p.is_dir() {
let children = scan(&p)?;
nodes.push((
None,
true,
Node::Dir {
name: fname,
path: p,
children,
},
));
} else if p.extension().is_some_and(|x| x == "yaml") {
if let Ok(req) = read_yaml::<Request>(&p) {
nodes.push((
req.order,
false,
Node::Req {
path: p,
req: Box::new(req),
},
));
}
}
}
nodes.sort_by(|a, b| {
b.1.cmp(&a.1)
.then(a.0.unwrap_or(u32::MAX).cmp(&b.0.unwrap_or(u32::MAX)))
.then(a.2.name().to_lowercase().cmp(&b.2.name().to_lowercase()))
});
Ok(nodes.into_iter().map(|n| n.2).collect())
}
pub mod registry {
use super::*;
fn path() -> PathBuf {
crate::config::config_dir().join("workspaces.yaml")
}
pub fn load() -> Vec<PathBuf> {
read_yaml::<Vec<PathBuf>>(&path())
.unwrap_or_default()
.into_iter()
.filter(|p| Workspace::is_workspace(p))
.collect()
}
pub fn add(root: &Path) -> Result<()> {
let root = root.canonicalize()?;
let mut all = load();
if !all.contains(&root) {
all.push(root);
write_yaml(&path(), &all)?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn init_roundtrip_and_secrets() {
let d = tempfile::tempdir().unwrap();
let ws = Workspace::init(d.path(), "demo").unwrap();
assert!(d.path().join(".gitignore").exists());
let p = ws.new_request_path(&ws.requests_dir(), "Get Thing");
ws.save_request(&p, &Request::new("Get Thing")).unwrap();
assert_eq!(ws.tree().unwrap().len(), 1);
ws.save_vars_text("default", "a=1\n# note\nsecret tok=x=y\n\nb = 2")
.unwrap();
assert_eq!(ws.vars_text("default").unwrap(), "a=1\nb=2\nsecret tok=x=y");
ws.set_var("default", "token", "s3", true).unwrap();
let v = ws.resolved_vars(Some("default"));
assert_eq!(v["token"], "s3");
assert!(
!fs::read_to_string(ws.env_path("default"))
.unwrap()
.contains("s3")
);
}
#[test]
fn slugs() {
assert_eq!(slug("GET /pet/{id}"), "get-pet-id");
assert_eq!(slug("!!"), "untitled");
}
}