src/store.rs 7.7 K raw
1
use std::fs;
2
use std::path::PathBuf;
3
4
use anyhow::{Context, Result, anyhow, bail};
5
use serde::{Deserialize, Serialize};
6
7
use crate::model::Collection;
8
9
/// Root config directory: `~/.config/cielago` on every platform, matching the
10
/// documented storage layout (rather than e.g. `~/Library/Application Support`
11
/// on macOS).
12
pub fn config_dir() -> Result<PathBuf> {
13
    let home = dirs::home_dir().ok_or_else(|| anyhow!("could not determine home directory"))?;
14
    let dir = home.join(".config").join("cielago");
15
    migrate_legacy_dirs(&home.join(".config"), &dir);
16
    Ok(dir)
17
}
18
19
/// Names this project shipped under before `cielago`, newest first.
20
const LEGACY_DIR_NAMES: [&str; 3] = ["manpost", "stableman", "getman"];
21
22
/// Move a leftover directory from an earlier name onto the current one, so
23
/// existing collections survive the rename. No-op once `cielago` exists.
24
fn migrate_legacy_dirs(config_root: &std::path::Path, new: &PathBuf) {
25
    if new.exists() {
26
        return;
27
    }
28
    for legacy in LEGACY_DIR_NAMES {
29
        let old = config_root.join(legacy);
30
        if old.exists() && fs::rename(&old, new).is_ok() {
31
            return;
32
        }
33
    }
34
}
35
36
pub fn collections_dir() -> Result<PathBuf> {
37
    Ok(config_dir()?.join("collections"))
38
}
39
40
/// Filesystem-safe slug for a collection name.
41
pub fn slugify(name: &str) -> String {
42
    let mut slug = String::new();
43
    let mut last_dash = false;
44
    for c in name.chars() {
45
        if c.is_ascii_alphanumeric() {
46
            slug.push(c.to_ascii_lowercase());
47
            last_dash = false;
48
        } else if !last_dash && !slug.is_empty() {
49
            slug.push('-');
50
            last_dash = true;
51
        }
52
    }
53
    let slug = slug.trim_matches('-').to_string();
54
    if slug.is_empty() {
55
        "collection".into()
56
    } else {
57
        slug
58
    }
59
}
60
61
pub fn collection_path(name: &str) -> Result<PathBuf> {
62
    Ok(collections_dir()?.join(format!("{}.json", slugify(name))))
63
}
64
65
pub fn save_collection(collection: &Collection) -> Result<PathBuf> {
66
    let dir = collections_dir()?;
67
    fs::create_dir_all(&dir).context("creating collections directory")?;
68
    let path = collection_path(&collection.name)?;
69
    let json = serde_json::to_string_pretty(collection)?;
70
    fs::write(&path, json).with_context(|| format!("writing {}", path.display()))?;
71
    Ok(path)
72
}
73
74
pub fn load_collection(name: &str) -> Result<Collection> {
75
    let path = collection_path(name)?;
76
    load_collection_path(&path)
77
}
78
79
pub fn load_collection_path(path: &PathBuf) -> Result<Collection> {
80
    let text = fs::read_to_string(path).with_context(|| format!("reading {}", path.display()))?;
81
    let collection = serde_json::from_str(&text)
82
        .with_context(|| format!("parsing collection at {}", path.display()))?;
83
    Ok(collection)
84
}
85
86
/// Resolve a user-typed collection name onto a saved one. Exact matches win;
87
/// otherwise anything that slugifies the same does, so `cielago delete "some
88
/// api"` finds `Some API`.
89
pub fn resolve_collection(name: &str) -> Result<String> {
90
    let names = list_collections()?;
91
    if let Some(found) = match_name(&names, name) {
92
        return Ok(found);
93
    }
94
    if names.is_empty() {
95
        bail!(
96
            "No collections yet. Import one:\n\n  cielago import <spec.json|yaml|url>\n\nOr create an empty one:\n\n  cielago new <name>"
97
        )
98
    }
99
    bail!(
100
        "No collection named {name:?}.\n\nAvailable: {}",
101
        names.join(", ")
102
    )
103
}
104
105
/// Pick the saved name a user-typed one refers to: exact match first, then any
106
/// name with the same slug (which is what the file is named after anyway).
107
fn match_name(names: &[String], input: &str) -> Option<String> {
108
    if names.iter().any(|n| n == input) {
109
        return Some(input.to_string());
110
    }
111
    let slug = slugify(input);
112
    names.iter().find(|n| slugify(n) == slug).cloned()
113
}
114
115
/// Delete a saved collection. Returns the file that was removed.
116
pub fn delete_collection(name: &str) -> Result<PathBuf> {
117
    let path = collection_path(name)?;
118
    fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
119
    Ok(path)
120
}
121
122
/// Names of all saved collections (derived from file names).
123
pub fn list_collections() -> Result<Vec<String>> {
124
    let dir = collections_dir()?;
125
    let mut names = Vec::new();
126
    if dir.exists() {
127
        for entry in fs::read_dir(&dir)? {
128
            let entry = entry?;
129
            let path = entry.path();
130
            if path.extension().and_then(|e| e.to_str()) == Some("json")
131
                && let Ok(c) = load_collection_path(&path)
132
            {
133
                names.push(c.name);
134
            }
135
        }
136
    }
137
    names.sort();
138
    Ok(names)
139
}
140
141
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
142
pub struct AppConfig {
143
    #[serde(default)]
144
    pub last_collection: Option<String>,
145
    #[serde(default)]
146
    pub editor: Option<String>,
147
}
148
149
impl AppConfig {
150
    fn path() -> Result<PathBuf> {
151
        Ok(config_dir()?.join("config.json"))
152
    }
153
154
    pub fn load() -> AppConfig {
155
        Self::path()
156
            .and_then(|p| Ok(fs::read_to_string(p)?))
157
            .and_then(|t| Ok(serde_json::from_str(&t)?))
158
            .unwrap_or_default()
159
    }
160
161
    pub fn save(&self) -> Result<()> {
162
        let dir = config_dir()?;
163
        fs::create_dir_all(&dir)?;
164
        fs::write(Self::path()?, serde_json::to_string_pretty(self)?)?;
165
        Ok(())
166
    }
167
168
    /// Editor to use for external body editing: config override, `$EDITOR`, else `vi`.
169
    pub fn editor_cmd(&self) -> String {
170
        self.editor
171
            .clone()
172
            .or_else(|| std::env::var("EDITOR").ok())
173
            .filter(|e| !e.is_empty())
174
            .unwrap_or_else(|| "vi".into())
175
    }
176
}
177
178
#[cfg(test)]
179
mod tests {
180
    use super::*;
181
    use crate::model::Method;
182
183
    #[test]
184
    fn slugify_basic() {
185
        assert_eq!(slugify("My Pet API"), "my-pet-api");
186
        assert_eq!(slugify("pets_v2 (internal)"), "pets-v2-internal");
187
        assert_eq!(slugify("!!!"), "collection");
188
        assert_eq!(slugify("a"), "a");
189
    }
190
191
    #[test]
192
    fn match_name_exact_then_slug() {
193
        let names = vec!["Some API".to_string(), "Other API".to_string()];
194
        assert_eq!(match_name(&names, "Some API").as_deref(), Some("Some API"));
195
        assert_eq!(match_name(&names, "some api").as_deref(), Some("Some API"));
196
        assert_eq!(match_name(&names, "some-api").as_deref(), Some("Some API"));
197
        assert_eq!(match_name(&names, "nope"), None);
198
    }
199
200
    #[test]
201
    fn collection_json_roundtrip() {
202
        let mut c = Collection::new("Test API");
203
        c.servers = vec![
204
            "https://a.example.com".into(),
205
            "https://b.example.com".into(),
206
        ];
207
        c.active_server = 1;
208
        c.variables
209
            .push(crate::model::KeyValueRow::new("tenant", "acme", true));
210
        c.auth = Some(crate::model::OAuthConfig {
211
            token_url: "https://auth.example.com/token".into(),
212
            client_id: "id".into(),
213
            client_secret: "secret".into(),
214
            scopes: vec!["read".into()],
215
            auth_style: crate::model::AuthStyle::Basic,
216
            ..Default::default()
217
        });
218
        let mut r = crate::model::SavedRequest::blank("list pets");
219
        r.method = Method::Post;
220
        r.query
221
            .push(crate::model::KeyValueRow::new("limit", "10", false));
222
        r.body = Some("{\"a\":1}".into());
223
        c.requests.push(r);
224
225
        let json = serde_json::to_string_pretty(&c).unwrap();
226
        let back: Collection = serde_json::from_str(&json).unwrap();
227
228
        assert_eq!(back.name, "Test API");
229
        assert_eq!(back.base_url(), Some("https://b.example.com"));
230
        assert_eq!(back.variables[0].value, "acme");
231
        assert_eq!(back.auth.as_ref().unwrap().client_secret, "secret");
232
        assert_eq!(back.requests.len(), 1);
233
        assert_eq!(back.requests[0].method, Method::Post);
234
        assert!(!back.requests[0].query[0].enabled);
235
    }
236
}