Skip to main content

buffrs/
command.rs

1// Copyright 2023 Helsing GmbH
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use std::{
16    env,
17    path::{Path, PathBuf},
18    str::FromStr,
19};
20
21use miette::{Context as _, IntoDiagnostic, bail, ensure, miette};
22use semver::{Version, VersionReq};
23use tokio::{
24    fs,
25    io::{AsyncBufReadExt, BufReader, stdin},
26};
27
28use crate::{
29    credentials::Credentials,
30    io::File,
31    manifest::{
32        MANIFEST_FILE, Manifest,
33        package::{Dependency, PackageManifest, PackagesManifest},
34    },
35    operations::install::{Install, InstallationContext, NetworkMode},
36    operations::publish::Publisher,
37    package::{PackageName, PackageStore, PackageType},
38    registry::{Artifactory, RegistryUri},
39};
40
41const INITIAL_VERSION: Version = Version::new(0, 1, 0);
42const BUFFRS_TESTSUITE_VAR: &str = "BUFFRS_TESTSUITE";
43
44/// Initializes the project
45pub async fn init(kind: Option<PackageType>, name: Option<PackageName>) -> miette::Result<()> {
46    if PackagesManifest::exists().await? {
47        bail!("a manifest file was found, project is already initialized");
48    }
49
50    fn curr_dir_name() -> miette::Result<PackageName> {
51        std::env::current_dir()
52            .into_diagnostic()?
53            .file_name()
54            // because the path originates from the current directory, this condition is never met
55            .ok_or(miette!(
56                "unexpected error: current directory path terminates in .."
57            ))?
58            .to_str()
59            .ok_or_else(|| miette!("current directory path is not valid utf-8"))?
60            .parse()
61    }
62
63    let package = kind
64        .map(|kind| -> miette::Result<PackageManifest> {
65            let name = name.map(Result::Ok).unwrap_or_else(curr_dir_name)?;
66
67            Ok(PackageManifest {
68                kind,
69                name,
70                version: INITIAL_VERSION,
71                description: None,
72            })
73        })
74        .transpose()?;
75
76    let mut builder = PackagesManifest::builder();
77
78    if let Some(pkg) = package {
79        builder = builder.package(pkg);
80    }
81
82    let manifest = builder.dependencies(Default::default()).build();
83
84    manifest.save_to(Path::new(".")).await?;
85
86    PackageStore::open(std::env::current_dir().unwrap_or_else(|_| ".".into()))
87        .await
88        .wrap_err("failed to create buffrs `proto` directories")?;
89
90    Ok(())
91}
92
93/// Initializes a project with the given name in the current directory
94pub async fn new(kind: Option<PackageType>, name: PackageName) -> miette::Result<()> {
95    let package_dir = PathBuf::from(name.to_string());
96    // create_dir fails if the folder already exists
97    fs::create_dir(&package_dir)
98        .await
99        .into_diagnostic()
100        .wrap_err_with(|| format!("failed to create {} directory", package_dir.display()))?;
101
102    let package = kind
103        .map(|kind| -> miette::Result<PackageManifest> {
104            Ok(PackageManifest {
105                kind,
106                name,
107                version: INITIAL_VERSION,
108                description: None,
109            })
110        })
111        .transpose()?;
112
113    let mut builder = PackagesManifest::builder();
114    if let Some(pkg) = package {
115        builder = builder.package(pkg);
116    }
117
118    let manifest = builder.dependencies(Default::default()).build();
119
120    manifest.save_to(package_dir.as_path()).await?;
121
122    PackageStore::open(&package_dir)
123        .await
124        .wrap_err("failed to create buffrs `proto` directories")?;
125
126    Ok(())
127}
128
129struct DependencyLocator {
130    repository: String,
131    package: PackageName,
132    version: DependencyLocatorVersion,
133}
134
135enum DependencyLocatorVersion {
136    Version(VersionReq),
137    Latest,
138}
139
140impl FromStr for DependencyLocator {
141    type Err = miette::Report;
142
143    fn from_str(dependency: &str) -> miette::Result<Self> {
144        let lower_kebab = |c: char| (c.is_lowercase() && c.is_ascii_alphabetic()) || c == '-';
145
146        let (repository, dependency) = dependency
147            .trim()
148            .split_once('/')
149            .ok_or_else(|| miette!("locator {dependency} is missing a repository delimiter"))?;
150
151        ensure!(
152            repository.chars().all(lower_kebab),
153            "repository {repository} is not in kebab case"
154        );
155
156        ensure!(!repository.is_empty(), "repository must not be empty");
157
158        let repository = repository.into();
159
160        let (package, version) = dependency
161            .split_once('@')
162            .map(|(package, version)| (package, Some(version)))
163            .unwrap_or_else(|| (dependency, None));
164
165        let package = package
166            .parse::<PackageName>()
167            .wrap_err_with(|| format!("invalid package name: {package}"))?;
168
169        let version = match version {
170            Some("latest") | None => DependencyLocatorVersion::Latest,
171            Some(version_str) => {
172                let parsed_version = VersionReq::parse(version_str)
173                    .into_diagnostic()
174                    .wrap_err_with(|| format!("not a valid version requirement: {version_str}"))?;
175                DependencyLocatorVersion::Version(parsed_version)
176            }
177        };
178
179        Ok(Self {
180            repository,
181            package,
182            version,
183        })
184    }
185}
186
187/// Adds a dependency to this project
188pub async fn add(registry: RegistryUri, dependency: &str) -> miette::Result<()> {
189    let manifest_path = PathBuf::from(MANIFEST_FILE);
190    let mut manifest = Manifest::require_package_manifest(&manifest_path).await?;
191
192    let DependencyLocator {
193        repository,
194        package,
195        version,
196    } = dependency.parse()?;
197
198    let version = match version {
199        DependencyLocatorVersion::Version(version_req) => version_req,
200        DependencyLocatorVersion::Latest => {
201            // query artifactory to retrieve the actual latest version
202            let credentials = Credentials::load().await?;
203            let artifactory = Artifactory::new(registry.clone(), &credentials)?;
204
205            let latest_version = artifactory
206                .get_latest_version(repository.clone(), package.clone())
207                .await?;
208            // Convert semver::Version to semver::VersionReq. It will default to operator `>`, which is what we want for Proto.toml
209            VersionReq::parse(&latest_version.to_string()).into_diagnostic()?
210        }
211    };
212
213    manifest
214        .dependencies
215        .get_or_insert_default()
216        .push(Dependency::new(registry, repository, package, version));
217
218    manifest
219        .save_to(Path::new("."))
220        .await
221        .wrap_err_with(|| format!("failed to write `{MANIFEST_FILE}`"))?;
222
223    Ok(())
224}
225
226/// Removes a dependency from this project
227pub async fn remove(package: PackageName) -> miette::Result<()> {
228    let manifest_path = PathBuf::from(MANIFEST_FILE);
229    let mut manifest = Manifest::require_package_manifest(&manifest_path).await?;
230    let store = PackageStore::current().await?;
231
232    let dependency = manifest
233        .dependencies
234        .iter()
235        .flatten()
236        .position(|d| d.package == package)
237        .ok_or_else(|| miette!("package {package} not in manifest"))?;
238
239    let dependency = manifest
240        .dependencies
241        .get_or_insert_default()
242        .remove(dependency);
243
244    store.uninstall(&dependency.package).await.ok();
245
246    manifest.save_to(Path::new(".")).await
247}
248
249/// Packages the api and writes it to the filesystem
250pub async fn package(
251    directory: impl AsRef<Path>,
252    dry_run: bool,
253    version: Option<Version>,
254    preserve_mtime: bool,
255) -> miette::Result<()> {
256    let manifest_path = PathBuf::from(MANIFEST_FILE);
257    let manifest = Manifest::require_package_manifest(&manifest_path)
258        .await?
259        .with_version(version);
260    let store = PackageStore::current().await?;
261
262    if let Some(ref pkg) = manifest.package {
263        store.populate(pkg).await?;
264    }
265
266    let package = store.release(&manifest, preserve_mtime).await?;
267
268    if dry_run {
269        return Ok(());
270    }
271
272    let path = {
273        let file = format!("{}-{}.tgz", package.name(), package.version());
274
275        directory.as_ref().join(file)
276    };
277
278    fs::write(path, package.tgz)
279        .await
280        .into_diagnostic()
281        .wrap_err("failed to write package release to the current directory")
282}
283
284/// Publishes the api package to the registry
285pub async fn publish(
286    registry: RegistryUri,
287    repository: String,
288    #[cfg(feature = "git")] allow_dirty: bool,
289    dry_run: bool,
290    version: Option<Version>,
291    preserve_mtime: bool,
292) -> miette::Result<()> {
293    #[cfg(feature = "git")]
294    Publisher::check_git_status(allow_dirty).await?;
295
296    let manifest = Manifest::load().await?;
297    let current_path = env::current_dir()
298        .into_diagnostic()
299        .wrap_err("current dir could not be retrieved")?;
300
301    let mut publisher = Publisher::new(registry, repository, preserve_mtime).await?;
302    publisher
303        .publish(&manifest, &current_path, version, dry_run)
304        .await
305}
306
307/// Installs dependencies for the current project
308///
309/// Behavior depends on the manifest type:
310/// - **Package**: Installs dependencies listed in the `[dependencies]` section
311/// - **Workspace**: Installs dependencies for all workspace members
312///
313/// # Arguments
314///
315/// * `preserve_mtime` - If true, local dependencies preserve their modification time
316/// * `network_mode` - Controls whether network requests are allowed
317pub async fn install(preserve_mtime: bool, network_mode: NetworkMode) -> miette::Result<()> {
318    let manifest = Manifest::load().await?;
319
320    let ctx = InstallationContext::cwd(preserve_mtime, network_mode).await?;
321
322    manifest.install(&ctx).await?;
323
324    Ok(())
325}
326
327/// Uninstalls dependencies
328///
329/// Behavior depends on the manifest type:
330/// - **Package**: Clears the package's vendor directory
331/// - **Workspace**: Clears vendor directories for all workspace members
332pub async fn uninstall() -> miette::Result<()> {
333    let manifest = Manifest::load().await?;
334
335    match manifest {
336        Manifest::Package(_) => PackageStore::current().await?.clear().await,
337        Manifest::Workspace(workspace_manifest) => {
338            let root_path = env::current_dir()
339                .into_diagnostic()
340                .wrap_err("current dir could not be retrieved")?;
341
342            let packages = workspace_manifest.workspace.members(root_path)?;
343
344            tracing::info!(
345                "workspace found. uninstalling dependencies for {} packages in workspace",
346                packages.len()
347            );
348
349            for package_path in packages {
350                tracing::info!(
351                    "uninstalling dependencies for package: {}",
352                    package_path.display()
353                );
354
355                let store = PackageStore::open(&package_path).await?;
356                store.clear().await?;
357            }
358
359            Ok(())
360        }
361    }
362}
363
364/// Lists all protobuf files managed by Buffrs to stdout
365pub async fn list() -> miette::Result<()> {
366    let manifest_path = PathBuf::from(MANIFEST_FILE);
367    let manifest = Manifest::require_package_manifest(&manifest_path).await?;
368    let store = PackageStore::current().await?;
369
370    if let Some(ref pkg) = manifest.package {
371        store.populate(pkg).await?;
372    }
373
374    let protos = store.collect(&store.proto_vendor_path(), true).await;
375
376    let cwd = {
377        let cwd = std::env::current_dir()
378            .into_diagnostic()
379            .wrap_err("failed to get current directory")?;
380
381        fs::canonicalize(cwd)
382            .await
383            .into_diagnostic()
384            .wrap_err("failed to canonicalize current directory")?
385    };
386
387    for proto in protos.iter() {
388        let rel = proto
389            .strip_prefix(&cwd)
390            .into_diagnostic()
391            .wrap_err("failed to transform protobuf path")?;
392
393        print!("{} ", rel.display())
394    }
395
396    Ok(())
397}
398
399/// Parses current package and validates rules.
400#[cfg(feature = "validation")]
401pub async fn lint() -> miette::Result<()> {
402    let manifest_path = PathBuf::from(MANIFEST_FILE);
403    let manifest = Manifest::require_package_manifest(&manifest_path).await?;
404    let store = PackageStore::current().await?;
405
406    let pkg = manifest.package.ok_or(miette!(
407        "a [package] section must be declared run the linter"
408    ))?;
409
410    store.populate(&pkg).await?;
411    let violations = store.validate(&pkg).await?;
412
413    violations
414        .into_iter()
415        .map(miette::Report::new)
416        .for_each(|r| eprintln!("{r:?}"));
417
418    Ok(())
419}
420
421/// Logs you in for a registry
422pub async fn login(registry: RegistryUri) -> miette::Result<()> {
423    let mut credentials = Credentials::load().await?;
424
425    tracing::info!("please enter your artifactory token:");
426
427    let token = {
428        let mut raw = String::new();
429        let mut reader = BufReader::new(stdin());
430
431        reader
432            .read_line(&mut raw)
433            .await
434            .into_diagnostic()
435            .wrap_err("failed to read the token from the user")?;
436
437        raw.trim().into()
438    };
439
440    credentials.registry_tokens.insert(registry.clone(), token);
441
442    if env::var(BUFFRS_TESTSUITE_VAR).is_err() {
443        Artifactory::new(registry, &credentials)?
444            .ping()
445            .await
446            .wrap_err("failed to validate token")?;
447    }
448
449    credentials.write().await
450}
451
452/// Logs you out from a registry
453pub async fn logout(registry: RegistryUri) -> miette::Result<()> {
454    let mut credentials = Credentials::load().await?;
455    credentials.registry_tokens.remove(&registry);
456    credentials.write().await
457}
458
459/// Commands on the lockfile
460pub mod lock {
461    use crate::io::File;
462    use crate::lock::{FileRequirement, Lockfile};
463
464    /// Prints the file requirements serialized as JSON
465    pub async fn print_files() -> miette::Result<()> {
466        let lock = Lockfile::load().await?;
467
468        let requirements: Vec<FileRequirement> = lock.into();
469
470        // hint: always ok, as per serde_json doc
471        if let Ok(json) = serde_json::to_string_pretty(&requirements) {
472            println!("{json}");
473        }
474
475        Ok(())
476    }
477}
478
479#[cfg(test)]
480mod tests {
481    use super::DependencyLocator;
482
483    #[test]
484    fn valid_dependency_locator() {
485        assert!("repo/pkg@1.0.0".parse::<DependencyLocator>().is_ok());
486        assert!("repo/pkg@=1.0".parse::<DependencyLocator>().is_ok());
487        assert!(
488            "repo-with-dash/pkg@=1.0"
489                .parse::<DependencyLocator>()
490                .is_ok()
491        );
492        assert!(
493            "repo-with-dash/pkg-with-dash@=1.0"
494                .parse::<DependencyLocator>()
495                .is_ok()
496        );
497        assert!(
498            "repo/pkg@=1.0.0-with-prerelease"
499                .parse::<DependencyLocator>()
500                .is_ok()
501        );
502        assert!("repo/pkg@latest".parse::<DependencyLocator>().is_ok());
503        assert!("repo/pkg".parse::<DependencyLocator>().is_ok());
504    }
505
506    #[test]
507    fn invalid_dependency_locators() {
508        assert!("/xyz@1.0.0".parse::<DependencyLocator>().is_err());
509        assert!("repo/@1.0.0".parse::<DependencyLocator>().is_err());
510        assert!("repo@1.0.0".parse::<DependencyLocator>().is_err());
511        assert!(
512            "repo/pkg@latestwithtypo"
513                .parse::<DependencyLocator>()
514                .is_err()
515        );
516        assert!("repo/pkg@=1#meta".parse::<DependencyLocator>().is_err());
517        assert!("repo/PKG@=1.0".parse::<DependencyLocator>().is_err());
518    }
519}