Skip to main content

greentic_setup/cli_helpers/
bundle.rs

1//! Bundle resolution and pack handling.
2//!
3//! Functions for resolving bundle sources (directories, .gtbundle files, URLs)
4//! and managing pack files.
5
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::time::{SystemTime, UNIX_EPOCH};
9
10use anyhow::{Context, Result, bail};
11use url::Url;
12
13use crate::cli_i18n::CliI18n;
14
15/// Resolve bundle source - supports both directories and .gtbundle files.
16pub fn resolve_bundle_source(path: &std::path::Path, i18n: &CliI18n) -> Result<PathBuf> {
17    use crate::gtbundle;
18
19    let path_str = path.to_string_lossy();
20    if path_str.starts_with("https://") || path_str.starts_with("http://") {
21        println!("{}", i18n.t("cli.simple.extracting"));
22        let temp_dir = download_and_extract_remote_bundle(&path_str)
23            .context("failed to fetch and extract remote bundle archive")?;
24        println!(
25            "{}",
26            i18n.tf(
27                "cli.simple.extracted_to",
28                &[&temp_dir.display().to_string()]
29            )
30        );
31        return Ok(temp_dir);
32    }
33
34    if gtbundle::is_gtbundle_file(path) {
35        println!("{}", i18n.t("cli.simple.extracting"));
36        let temp_dir = gtbundle::extract_gtbundle_to_temp(path)
37            .context("failed to extract .gtbundle archive")?;
38        println!(
39            "{}",
40            i18n.tf(
41                "cli.simple.extracted_to",
42                &[&temp_dir.display().to_string()]
43            )
44        );
45        return Ok(temp_dir);
46    }
47
48    if gtbundle::is_gtbundle_dir(path) {
49        return Ok(path.to_path_buf());
50    }
51    if path_str.ends_with(".gtbundle") && !path.exists() {
52        bail!(
53            "{}",
54            i18n.tf(
55                "setup.error.bundle_not_found",
56                &[&path.display().to_string()]
57            )
58        );
59    }
60
61    if path.is_dir() {
62        Ok(path.to_path_buf())
63    } else if path.exists() {
64        bail!(
65            "{}",
66            i18n.tf(
67                "cli.simple.expected_bundle_format",
68                &[&path.display().to_string()]
69            )
70        );
71    } else {
72        bail!(
73            "{}",
74            i18n.tf(
75                "setup.error.bundle_not_found",
76                &[&path.display().to_string()]
77            )
78        );
79    }
80}
81
82/// Persistent output target for simple setup flows.
83pub enum SetupOutputTarget {
84    Directory(PathBuf),
85    Archive(PathBuf),
86}
87
88/// Decide whether simple setup should materialize a configured local bundle.
89///
90/// - For remote `https://.../*.gtbundle`, write `./<file-name>.gtbundle` as a
91///   local bundle directory, matching the normal `gtc start ./demo.gtbundle`
92///   workspace flow.
93/// - For local archive paths, update that same archive in place.
94/// - For local bundle directories, keep working in the directory and do not emit
95///   a new artifact automatically.
96pub fn setup_output_target(source: &Path) -> Result<Option<SetupOutputTarget>> {
97    let source_str = source.to_string_lossy();
98
99    if source_str.starts_with("https://") || source_str.starts_with("http://") {
100        let parsed =
101            Url::parse(&source_str).with_context(|| format!("invalid bundle URL: {source_str}"))?;
102        let file_name = parsed
103            .path_segments()
104            .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
105            .filter(|segment| segment.ends_with(".gtbundle"))
106            .ok_or_else(|| {
107                anyhow::anyhow!("remote bundle URL must point to a .gtbundle archive")
108            })?;
109        return Ok(Some(SetupOutputTarget::Directory(
110            std::env::current_dir()?.join(file_name),
111        )));
112    }
113
114    if source_str.ends_with(".gtbundle") {
115        return Ok(Some(SetupOutputTarget::Archive(source.to_path_buf())));
116    }
117
118    Ok(None)
119}
120
121/// Download and extract a remote bundle archive.
122fn download_and_extract_remote_bundle(url: &str) -> Result<PathBuf> {
123    use crate::gtbundle;
124
125    let response = ureq::get(url)
126        .call()
127        .map_err(|err| anyhow::anyhow!("failed to fetch {url}: {err}"))?;
128    let bytes = response
129        .into_body()
130        .read_to_vec()
131        .map_err(|err| anyhow::anyhow!("failed to read {url}: {err}"))?;
132
133    let nonce = SystemTime::now()
134        .duration_since(UNIX_EPOCH)
135        .unwrap_or_default()
136        .as_nanos();
137    let base = std::env::temp_dir().join(format!("greentic-setup-remote-{nonce}"));
138    fs::create_dir_all(&base)?;
139
140    let file_name = url
141        .rsplit('/')
142        .next()
143        .filter(|value| !value.is_empty())
144        .unwrap_or("bundle.gtbundle");
145    let archive_path = base.join(file_name);
146    fs::write(&archive_path, bytes)?;
147
148    if !gtbundle::is_gtbundle_file(&archive_path) {
149        bail!("remote bundle URL must point to a .gtbundle archive: {url}");
150    }
151
152    gtbundle::extract_gtbundle_to_temp(&archive_path)
153}
154
155/// Resolve bundle directory from optional path argument.
156pub fn resolve_bundle_dir(bundle: Option<PathBuf>) -> Result<PathBuf> {
157    match bundle {
158        Some(path) => Ok(path),
159        None => std::env::current_dir().context("failed to get current directory"),
160    }
161}
162
163/// Recursively copy a directory tree.
164pub fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf, _only_used: bool) -> Result<()> {
165    if !src.is_dir() {
166        bail!("source is not a directory: {}", src.display());
167    }
168
169    std::fs::create_dir_all(dst)?;
170
171    for entry in std::fs::read_dir(src)? {
172        let entry = entry?;
173        let src_path = entry.path();
174        let dst_path = dst.join(entry.file_name());
175
176        if src_path.is_dir() {
177            copy_dir_recursive(&src_path, &dst_path, _only_used)?;
178        } else {
179            std::fs::copy(&src_path, &dst_path)?;
180        }
181    }
182
183    Ok(())
184}
185
186/// Detect provider domain from .gtpack filename prefix.
187///
188/// Known prefixes: messaging-, state-, telemetry-, events-, oauth-, secrets-.
189/// Falls back to "messaging" for unrecognized prefixes.
190pub fn detect_domain_from_filename(filename: &str) -> &'static str {
191    let stem = filename.strip_suffix(".gtpack").unwrap_or(filename);
192    if stem.starts_with("messaging-")
193        || stem.starts_with("state-")
194        || stem.starts_with("telemetry-")
195    {
196        "messaging"
197    } else if stem.starts_with("events-") || stem.starts_with("event-") {
198        "events"
199    } else if stem.starts_with("oauth-") {
200        "oauth"
201    } else if stem.starts_with("secrets-") {
202        "secrets"
203    } else {
204        "messaging"
205    }
206}
207
208/// Resolve a pack source (local path or OCI reference) to a local file path.
209pub fn resolve_pack_source(source: &str) -> Result<PathBuf> {
210    use crate::bundle_source::BundleSource;
211
212    let parsed = BundleSource::parse(source)?;
213
214    if parsed.is_local() {
215        let path = parsed.resolve()?;
216        if path.extension().and_then(|e| e.to_str()) != Some("gtpack") {
217            anyhow::bail!("Not a .gtpack file: {source}");
218        }
219        Ok(path)
220    } else {
221        println!("    Fetching from registry...");
222        let path = parsed.resolve()?;
223        println!("    Downloaded to cache: {}", path.display());
224        Ok(path)
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::{SetupOutputTarget, setup_output_target};
231    use std::env;
232    use std::path::Path;
233    use tempfile::tempdir;
234
235    #[test]
236    fn setup_output_target_uses_cwd_file_name_directory_for_remote_bundle_urls() {
237        let cwd = env::current_dir().expect("cwd");
238        let dir = tempdir().expect("tempdir");
239        env::set_current_dir(dir.path()).expect("set cwd");
240
241        let output = setup_output_target(Path::new(
242            "https://github.com/greenticai/greentic-demo/releases/download/v0.1.9/cloud-deploy-demo.gtbundle",
243        ))
244        .expect("output target")
245        .expect("some output target");
246
247        match output {
248            SetupOutputTarget::Directory(path) => {
249                assert_eq!(path, dir.path().join("cloud-deploy-demo.gtbundle"));
250            }
251            SetupOutputTarget::Archive(path) => {
252                panic!("expected directory output, got archive {}", path.display());
253            }
254        }
255
256        env::set_current_dir(cwd).expect("restore cwd");
257    }
258
259    #[test]
260    fn setup_output_target_updates_local_archives_in_place() {
261        let archive = Path::new("/tmp/demo-bundle.gtbundle");
262        let output = setup_output_target(archive)
263            .expect("output target")
264            .expect("some output target");
265        match output {
266            SetupOutputTarget::Archive(path) => assert_eq!(path, archive),
267            SetupOutputTarget::Directory(path) => {
268                panic!("expected archive output, got directory {}", path.display());
269            }
270        }
271    }
272
273    #[test]
274    fn setup_output_target_skips_local_bundle_directories() {
275        let output = setup_output_target(Path::new("./demo-bundle")).expect("output target");
276        assert!(output.is_none());
277    }
278}