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.
83#[derive(Clone)]
84pub enum SetupOutputTarget {
85    Directory(PathBuf),
86    Archive(PathBuf),
87}
88
89/// Decide whether simple setup should materialize a configured local bundle.
90///
91/// - For remote `https://.../*.gtbundle`, write `./<file-name>.gtbundle` as a
92///   local bundle directory, matching the normal `gtc start ./demo.gtbundle`
93///   workspace flow.
94/// - For local archive paths, update that same archive in place.
95/// - For local bundle directories, keep working in the directory and do not emit
96///   a new artifact automatically.
97pub fn setup_output_target(source: &Path) -> Result<Option<SetupOutputTarget>> {
98    let source_str = source.to_string_lossy();
99
100    if source_str.starts_with("https://") || source_str.starts_with("http://") {
101        let parsed =
102            Url::parse(&source_str).with_context(|| format!("invalid bundle URL: {source_str}"))?;
103        let file_name = parsed
104            .path_segments()
105            .and_then(|mut segments| segments.rfind(|segment| !segment.is_empty()))
106            .filter(|segment| segment.ends_with(".gtbundle"))
107            .ok_or_else(|| {
108                anyhow::anyhow!("remote bundle URL must point to a .gtbundle archive")
109            })?;
110        return Ok(Some(SetupOutputTarget::Directory(
111            std::env::current_dir()?.join(file_name),
112        )));
113    }
114
115    if source_str.ends_with(".gtbundle") {
116        return Ok(Some(SetupOutputTarget::Archive(source.to_path_buf())));
117    }
118
119    Ok(None)
120}
121
122/// Download and extract a remote bundle archive.
123fn download_and_extract_remote_bundle(url: &str) -> Result<PathBuf> {
124    use crate::gtbundle;
125
126    let response = ureq::get(url)
127        .call()
128        .map_err(|err| anyhow::anyhow!("failed to fetch {url}: {err}"))?;
129    let bytes = response
130        .into_body()
131        .read_to_vec()
132        .map_err(|err| anyhow::anyhow!("failed to read {url}: {err}"))?;
133
134    let nonce = SystemTime::now()
135        .duration_since(UNIX_EPOCH)
136        .unwrap_or_default()
137        .as_nanos();
138    let base = std::env::temp_dir().join(format!("greentic-setup-remote-{nonce}"));
139    fs::create_dir_all(&base)?;
140
141    let file_name = url
142        .rsplit('/')
143        .next()
144        .filter(|value| !value.is_empty())
145        .unwrap_or("bundle.gtbundle");
146    let archive_path = base.join(file_name);
147    fs::write(&archive_path, bytes)?;
148
149    if !gtbundle::is_gtbundle_file(&archive_path) {
150        bail!("remote bundle URL must point to a .gtbundle archive: {url}");
151    }
152
153    gtbundle::extract_gtbundle_to_temp(&archive_path)
154}
155
156/// Resolve bundle directory from optional path argument.
157pub fn resolve_bundle_dir(bundle: Option<PathBuf>) -> Result<PathBuf> {
158    match bundle {
159        Some(path) => Ok(path),
160        None => std::env::current_dir().context("failed to get current directory"),
161    }
162}
163
164/// Recursively copy a directory tree.
165pub fn copy_dir_recursive(src: &PathBuf, dst: &PathBuf, _only_used: bool) -> Result<()> {
166    if !src.is_dir() {
167        bail!("source is not a directory: {}", src.display());
168    }
169
170    std::fs::create_dir_all(dst)?;
171
172    for entry in std::fs::read_dir(src)? {
173        let entry = entry?;
174        let src_path = entry.path();
175        let dst_path = dst.join(entry.file_name());
176
177        if src_path.is_dir() {
178            copy_dir_recursive(&src_path, &dst_path, _only_used)?;
179        } else {
180            std::fs::copy(&src_path, &dst_path)?;
181        }
182    }
183
184    Ok(())
185}
186
187/// Detect provider domain from .gtpack filename prefix.
188///
189/// Known prefixes: messaging-, state-, telemetry-, events-, oauth-, secrets-.
190/// Falls back to "messaging" for unrecognized prefixes.
191pub fn detect_domain_from_filename(filename: &str) -> &'static str {
192    let stem = filename.strip_suffix(".gtpack").unwrap_or(filename);
193    if stem.starts_with("messaging-")
194        || stem.starts_with("state-")
195        || stem.starts_with("telemetry-")
196    {
197        "messaging"
198    } else if stem.starts_with("events-") || stem.starts_with("event-") {
199        "events"
200    } else if stem.starts_with("oauth-") {
201        "oauth"
202    } else if stem.starts_with("secrets-") {
203        "secrets"
204    } else {
205        "messaging"
206    }
207}
208
209/// Resolve a pack source (local path or OCI reference) to a local file path.
210pub fn resolve_pack_source(source: &str) -> Result<PathBuf> {
211    use crate::bundle_source::BundleSource;
212
213    let parsed = BundleSource::parse(source)?;
214
215    if parsed.is_local() {
216        let path = parsed.resolve()?;
217        if path.extension().and_then(|e| e.to_str()) != Some("gtpack") {
218            anyhow::bail!("Not a .gtpack file: {source}");
219        }
220        Ok(path)
221    } else {
222        println!("    Fetching from registry...");
223        let path = parsed.resolve()?;
224        println!("    Downloaded to cache: {}", path.display());
225        Ok(path)
226    }
227}
228
229#[cfg(test)]
230mod tests {
231    use super::{SetupOutputTarget, setup_output_target};
232    use std::env;
233    use std::path::Path;
234    use tempfile::tempdir;
235
236    #[test]
237    fn setup_output_target_uses_cwd_file_name_directory_for_remote_bundle_urls() {
238        let cwd = env::current_dir().expect("cwd");
239        let dir = tempdir().expect("tempdir");
240        env::set_current_dir(dir.path()).expect("set cwd");
241
242        let output = setup_output_target(Path::new(
243            "https://github.com/greenticai/greentic-demo/releases/download/v0.1.9/cloud-deploy-demo.gtbundle",
244        ))
245        .expect("output target")
246        .expect("some output target");
247
248        match output {
249            SetupOutputTarget::Directory(path) => {
250                let expected = env::current_dir()
251                    .expect("cwd after set")
252                    .join("cloud-deploy-demo.gtbundle");
253                assert_eq!(path, expected);
254            }
255            SetupOutputTarget::Archive(path) => {
256                panic!("expected directory output, got archive {}", path.display());
257            }
258        }
259
260        env::set_current_dir(cwd).expect("restore cwd");
261    }
262
263    #[test]
264    fn setup_output_target_updates_local_archives_in_place() {
265        let archive = Path::new("/tmp/demo-bundle.gtbundle");
266        let output = setup_output_target(archive)
267            .expect("output target")
268            .expect("some output target");
269        match output {
270            SetupOutputTarget::Archive(path) => assert_eq!(path, archive),
271            SetupOutputTarget::Directory(path) => {
272                panic!("expected archive output, got directory {}", path.display());
273            }
274        }
275    }
276
277    #[test]
278    fn setup_output_target_skips_local_bundle_directories() {
279        let output = setup_output_target(Path::new("./demo-bundle")).expect("output target");
280        assert!(output.is_none());
281    }
282}