use std::path::Path;
use std::path::PathBuf;
use clap::CommandFactory;
use clap::FromArgMatches;
use clap::Parser;
use clap::Subcommand;
use clap::builder::TypedValueParser as _;
use tracing::log;
use quilt_rs::flow::UserMeta;
use quilt_rs::io::remote::WorkflowIntent;
use quilt_rs::logging::InvalidLogFilter;
use quilt_uri::Host;
use quilt_uri::Namespace;
mod browse;
mod commit;
mod create;
mod gc;
mod history;
mod home;
mod install;
mod list;
mod login;
mod model;
mod output;
mod pull;
mod push;
mod remove_revisions;
mod role;
mod status;
mod text;
mod undo_commit;
mod uninstall;
#[cfg(test)]
mod fixtures;
use model::Commands as _;
use model::Model;
pub use output::Format;
pub use output::Std;
pub use output::print;
const DOMAIN_DIR_NAMESPACE: &str = "com.quiltdata.quilt-sync";
const DOMAIN_ENV: &str = "QUILT_DOMAIN";
const FORMAT_ENV: &str = "QUILT_FORMAT";
fn format_value_parser() -> impl clap::builder::TypedValueParser<Value = String> {
clap::builder::PossibleValuesParser::new([
clap::builder::PossibleValue::new("text"),
clap::builder::PossibleValue::new("json"),
clap::builder::PossibleValue::new("").hide(true),
])
}
pub fn log_filter(value: &str) -> Result<Option<String>, InvalidLogFilter> {
let parsed = quilt_rs::logging::directives(value)?;
if let Some(parsed) = &parsed {
tracing_subscriber::EnvFilter::builder()
.parse(parsed)
.map_err(|_| InvalidLogFilter::new(value))?;
}
Ok(parsed)
}
fn parse_log_flag(value: &str) -> Result<String, InvalidLogFilter> {
log_filter(value)?.ok_or_else(|| InvalidLogFilter::new(value))
}
fn commit_workflow_intent(
workflow: Option<&str>,
no_workflow: bool,
) -> Result<WorkflowIntent, Error> {
if no_workflow {
Ok(WorkflowIntent::NoWorkflow)
} else if matches!(workflow, Some(id) if id.trim().is_empty()) {
Err(Error::WorkflowEmpty)
} else {
Ok(WorkflowIntent::from_optional_id(workflow))
}
}
fn parse_optional_namespace(namespace: Option<String>) -> Result<Option<Namespace>, Error> {
Ok(match namespace {
Some(namespace) => Some(namespace.try_into()?),
None => None,
})
}
fn get_domain_dir(dir_arg: Option<PathBuf>) -> Result<PathBuf, Error> {
match dir_arg {
Some(user_specified_dir) => Ok(user_specified_dir),
None => match dirs::data_local_dir() {
Some(default_user_dir) => Ok(default_user_dir.join(DOMAIN_DIR_NAMESPACE)),
None => Err(Error::Domain),
},
}
}
fn get_default_home_dir() -> Result<PathBuf, Error> {
default_home_dir_under(user_home_dir())
}
fn default_home_dir_under(user_home: Option<PathBuf>) -> Result<PathBuf, Error> {
user_home
.map(|user_home| user_home.join(quilt_rs::DEFAULT_HOME_DIR_NAME))
.ok_or(Error::Home)
}
#[cfg(not(test))]
fn user_home_dir() -> Option<PathBuf> {
dirs::home_dir()
}
#[cfg(test)]
#[expect(
clippy::unnecessary_wraps,
reason = "the same signature as the real lookup it stands in for"
)]
fn user_home_dir() -> Option<PathBuf> {
static USER_HOME: std::sync::OnceLock<tempfile::TempDir> = std::sync::OnceLock::new();
let user_home = USER_HOME
.get_or_init(|| tempfile::tempdir().expect("a temporary folder for the user home"));
Some(user_home.path().to_path_buf())
}
async fn initialize_home(
model: &Model,
home: Option<PathBuf>,
stderr: &mut impl std::io::Write,
) -> Result<(), Error> {
if let Some(dir) = home {
writeln!(
stderr,
"warning: --home is deprecated; use \"quilt home <dir>\""
)?;
home::set(model.get_local_domain(), &dir, false, home::Via::HomeFlag).await?;
} else {
match model.get_home().await {
Ok(_) => {}
Err(Error::Quilt(quilt_rs::Error::Lineage(
quilt_rs::LineageError::Missing | quilt_rs::LineageError::MissingHome,
))) => {
model.set_home(get_default_home_dir()?).await?;
}
Err(err) => return Err(err),
}
}
model.get_home().await?;
Ok(())
}
fn namespace_from_working_dir(home: &Path, current_dir: &Path) -> Result<Namespace, Error> {
let home = std::fs::canonicalize(home).unwrap_or_else(|_| home.to_path_buf());
let current_dir =
std::fs::canonicalize(current_dir).unwrap_or_else(|_| current_dir.to_path_buf());
let mut components = current_dir
.strip_prefix(&home)
.map_err(|_| Error::NamespaceRequired)?
.components();
let prefix = components
.next()
.and_then(|component| component.as_os_str().to_str())
.ok_or(Error::NamespaceRequired)?;
let name = components
.next()
.and_then(|component| component.as_os_str().to_str())
.ok_or(Error::NamespaceRequired)?;
Namespace::try_from(format!("{prefix}/{name}")).map_err(Error::from)
}
async fn resolve_namespace(model: &Model, namespace: Option<String>) -> Result<Namespace, Error> {
if let Some(namespace) = namespace {
return Ok(namespace.try_into()?);
}
let home = model.get_home().await?;
let current_dir = std::env::current_dir()?;
namespace_from_working_dir(home.as_ref(), ¤t_dir)
}
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct Args {
#[command(subcommand)]
command: Commands,
#[arg(long, value_name = "DIR")]
home: Option<PathBuf>,
#[arg(
short,
long,
global = true,
env = DOMAIN_ENV,
value_parser = clap::builder::OsStringValueParser::new().map(PathBuf::from),
)]
domain: Option<PathBuf>,
#[arg(short, long, global = true)]
verbose: bool,
#[arg(long, global = true, value_name = "FILTER", value_parser = parse_log_flag)]
log: Option<String>,
#[arg(
long,
global = true,
env = FORMAT_ENV,
value_name = "FORMAT",
value_parser = format_value_parser(),
)]
format: Option<String>,
#[arg(long, global = true)]
json: bool,
}
impl Args {
pub fn try_parse_with_env<I, T>(argv: I) -> Result<Self, clap::Error>
where
I: IntoIterator<Item = T>,
T: Into<std::ffi::OsString> + Clone,
{
let mut command = Self::command();
let mut matches = command.try_get_matches_from_mut(argv)?;
let domain_source = matches.value_source("domain");
let format_source = matches.value_source("format");
let mut parsed =
Self::from_arg_matches_mut(&mut matches).map_err(|err| err.format(&mut command))?;
if parsed
.domain
.as_ref()
.is_some_and(|domain| domain.as_os_str().is_empty())
{
parsed.domain = None;
reject_empty_flag(&mut command, domain_source, "--domain <DOMAIN>")?;
}
if parsed.format.as_deref() == Some("") {
parsed.format = None;
reject_empty_flag(&mut command, format_source, "--format <FORMAT>")?;
}
if parsed.json && format_source == Some(clap::parser::ValueSource::CommandLine) {
return Err(command.error(
clap::error::ErrorKind::ArgumentConflict,
"the argument '--json' cannot be used with '--format <FORMAT>'",
));
}
if parsed.verbose && parsed.log.is_some() {
return Err(command.error(
clap::error::ErrorKind::ArgumentConflict,
"the argument '--verbose' cannot be used with '--log <FILTER>'",
));
}
Ok(parsed)
}
pub fn log_directives(&self) -> Option<String> {
if self.verbose {
log_filter("info").expect("a level")
} else {
self.log.clone()
}
}
pub fn format(&self) -> Format {
if self.json || self.format.as_deref() == Some("json") {
Format::Json
} else {
Format::Text
}
}
}
fn reject_empty_flag(
command: &mut clap::Command,
source: Option<clap::parser::ValueSource>,
flag: &str,
) -> Result<(), clap::Error> {
if source == Some(clap::parser::ValueSource::EnvVariable) {
Ok(())
} else {
Err(command.error(
clap::error::ErrorKind::InvalidValue,
format!("a value is required for '{flag}' but none was supplied"),
))
}
}
#[derive(clap::Args, Debug)]
struct PackageRef {
#[arg(short, long)]
namespace: Option<String>,
}
impl PackageRef {
async fn resolve(self, model: &Model) -> Result<Namespace, Error> {
resolve_namespace(model, self.namespace).await
}
}
#[derive(Subcommand, Debug)]
enum Commands {
Browse {
#[arg(value_name = "PKG_URI")]
uri: String,
},
Create {
#[arg(short, long)]
namespace: String,
#[arg(short, long)]
source: Option<PathBuf>,
#[arg(short, long)]
message: Option<String>,
},
Commit {
#[arg(short, long)]
message: String,
#[arg(short, long)]
user_meta: Option<String>,
#[command(flatten)]
pkg: PackageRef,
#[arg(short, long)]
workflow: Option<String>,
#[arg(long, conflicts_with = "workflow")]
no_workflow: bool,
},
Gc,
Home {
#[arg(value_name = "DIR")]
dir: Option<PathBuf>,
#[arg(long, requires = "dir")]
overwrite: bool,
},
Install {
#[arg(value_name = "PKG_URI")]
uri: String,
#[arg(short, long)]
namespace: Option<String>,
#[arg(short, long)]
path: Option<Vec<PathBuf>>,
},
Login {
#[arg(short, long)]
code: Option<String>,
#[arg(long)]
host: Host,
},
List {
#[arg(long)]
fetch: bool,
},
Log {
#[command(flatten)]
pkg: PackageRef,
},
Pull {
#[command(flatten)]
pkg: PackageRef,
},
#[command(group(clap::ArgGroup::new("push_host").args(["host", "origin"])))]
Push {
#[command(flatten)]
pkg: PackageRef,
#[arg(short, long, requires = "push_host")]
bucket: Option<String>,
#[arg(long, requires = "bucket")]
host: Option<Host>,
#[arg(short, long, requires = "bucket", conflicts_with = "host")]
origin: Option<Host>,
#[arg(short, long)]
workflow: Option<String>,
#[arg(long, conflicts_with = "workflow")]
no_workflow: bool,
},
RemoveRevisions {
#[command(flatten)]
pkg: PackageRef,
#[arg(short, long, value_name = "N", value_parser = remove_revisions::parse_count)]
count: Option<std::num::NonZeroUsize>,
},
Role {
#[arg(long)]
host: Host,
#[arg(long, value_name = "ROLE")]
set: Option<String>,
},
Status {
#[command(flatten)]
pkg: PackageRef,
},
UndoCommit {
#[command(flatten)]
pkg: PackageRef,
},
Uninstall {
#[command(flatten)]
pkg: PackageRef,
#[arg(long)]
prune: bool,
},
}
pub const WAITING_NOTICE: &str = "waiting for another quilt process…";
pub fn notice_lock_waits(print: impl Fn(&str) + Send + Sync + 'static) {
quilt_rs::on_package_lock_wait(move || print(WAITING_NOTICE));
}
fn push_host(
host: Option<Host>,
origin: Option<Host>,
stderr: &mut impl std::io::Write,
) -> Result<Option<Host>, Error> {
if origin.is_some() {
writeln!(stderr, "warning: --origin is deprecated; use --host")?;
}
Ok(host.or(origin))
}
impl Commands {
fn needs_default_home(&self) -> bool {
!matches!(
self,
Commands::Login { .. }
| Commands::Browse { .. }
| Commands::Role { .. }
| Commands::Home { dir: Some(_), .. }
)
}
}
#[allow(
clippy::too_many_lines,
reason = "cohesive top-level CLI command dispatch"
)]
pub async fn init(args: Args) -> Result<Std, Error> {
let root_dir = get_domain_dir(args.domain)?;
let m = Model::from(root_dir);
if matches!(args.command, Commands::Home { dir: Some(_), .. }) && args.home.is_some() {
return Err(Error::HomeTwice);
}
if args.home.is_some() || args.command.needs_default_home() {
initialize_home(&m, args.home, &mut std::io::stderr()).await?;
}
match args.command {
Commands::Browse { uri } => {
let args = browse::Input { uri };
log::debug!("Browsing {args:?}");
Ok(browse::command(m, args).await)
}
Commands::Create {
namespace,
source,
message,
} => {
let args = create::Input {
namespace: namespace.try_into()?,
source,
message,
};
log::debug!("Creating {args:?}");
Ok(create::command(m, args).await)
}
Commands::Commit {
pkg,
message,
user_meta,
workflow,
no_workflow,
} => {
let namespace = pkg.resolve(&m).await?;
let user_meta = match &user_meta {
Some(object) => match serde_json::from_str(object)? {
serde_json::Value::Object(object) => {
UserMeta::Set(serde_json::Value::Object(object))
}
_ => {
return Err(Error::CommitMetaInvalid(object.clone()));
}
},
None => UserMeta::Keep,
};
let workflow = commit_workflow_intent(workflow.as_deref(), no_workflow)?;
let args = commit::Input {
message,
namespace,
user_meta,
workflow,
host_config: None,
};
log::debug!("Committing {args:?}");
Ok(commit::command(m, args).await)
}
Commands::Gc => {
log::debug!("Collecting garbage");
Ok(gc::command(m).await)
}
Commands::Home { dir, overwrite } => {
let args = home::Input { dir, overwrite };
log::debug!("Home {args:?}");
Ok(home::command(m, args).await)
}
Commands::Install {
namespace,
path,
uri,
} => {
let args = install::Input {
namespace: parse_optional_namespace(namespace)?,
paths: path,
uri,
};
log::debug!("Installing {args:?}");
Ok(install::command(m, args).await)
}
Commands::Login { code, host } => {
if let Some(code) = code {
let args = login::Input { code, host };
log::debug!("Logging in {args:?}");
Ok(login::command(m, args).await)
} else {
Ok(Std::Err(Error::LoginRequired(host)))
}
}
Commands::List { fetch } => {
let args = list::Input { fetch };
log::info!("Listing installed packages {args:?}");
Ok(list::command(m, args).await)
}
Commands::Log { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = history::Input { namespace };
log::debug!("Logging {args:?}");
Ok(history::command(m, args).await)
}
Commands::Pull { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = pull::Input {
namespace,
host_config: None,
};
log::debug!("Pull {args:?}");
Ok(pull::command(m, args).await)
}
Commands::Push {
pkg,
bucket,
host,
origin,
workflow,
no_workflow,
} => {
let origin = push_host(host, origin, &mut std::io::stderr())?;
let namespace = pkg.resolve(&m).await?;
if bucket.is_none() && (workflow.is_some() || no_workflow) {
return Err(Error::WorkflowRequiresBucket);
}
let workflow = commit_workflow_intent(workflow.as_deref(), no_workflow)?;
let args = push::Input {
namespace,
host_config: None,
bucket,
origin,
workflow,
};
log::debug!("Pushing {args:?}");
Ok(push::command(m, args).await)
}
Commands::RemoveRevisions { pkg, count } => {
let namespace = pkg.resolve(&m).await?;
let args = remove_revisions::Input { namespace, count };
log::debug!("Removing old revisions {args:?}");
Ok(remove_revisions::command(m, args).await)
}
Commands::Role { host, set } => {
let args = role::Input { host, set };
log::debug!("Role {args:?}");
Ok(role::command(m, args).await)
}
Commands::Status { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = status::Input {
namespace,
host_config: None,
};
log::debug!("Status {args:?}");
Ok(status::command(m, args).await)
}
Commands::UndoCommit { pkg } => {
let namespace = pkg.resolve(&m).await?;
let args = undo_commit::Input { namespace };
log::debug!("Undoing commit {args:?}");
Ok(undo_commit::command(m, args).await)
}
Commands::Uninstall { pkg, prune } => {
let namespace = pkg.resolve(&m).await?;
let args = uninstall::Input { namespace, prune };
log::debug!("Uninstalling {args:?}");
Ok(uninstall::command(m, args).await)
}
}
}
#[derive(thiserror::Error, Debug)]
pub enum Error {
#[error("Domain directory is required. We store files and credentials there")]
Domain,
#[error("Could not determine the default home directory. Run \"quilt home <dir>\" to set one")]
Home,
#[error("{reason}. Moving the home isn't supported yet. {retry}")]
HomeInUse { reason: String, retry: home::Retry },
#[error("--home and \"quilt home <dir>\" both set the home; pass only \"quilt home <dir>\"")]
HomeTwice,
#[error("Cannot free space: {0} is busy in another quilt process. Try again once it finishes")]
PackageBusy(Namespace),
#[error("quilt_rs error: {0}")]
Quilt(quilt_rs::Error),
#[error(
r"
Please visit https://{0}/code to get your code.
Then run:
> quilt login --host {0} --code YOUR_CODE"
)]
LoginRequired(Host),
#[error("Package {0} not found")]
NamespaceNotFound(Namespace),
#[error(
"Could not infer a namespace from the current directory. Pass --namespace or run inside <home>/<prefix>/<name>"
)]
NamespaceRequired,
#[error("Invalid JSON for user_meta object. Object is required")]
CommitMetaInvalid(String),
#[error("workflow id cannot be empty; omit --workflow to use the bucket's default workflow")]
WorkflowEmpty,
#[error(
"--workflow/--no-workflow apply only to a first push; pass --bucket and --host to set the remote, or omit them (the workflow was chosen at commit time)"
)]
WorkflowRequiresBucket,
#[error("QUILT_LOG={0}")]
LogEnv(quilt_rs::logging::InvalidLogFilter),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[cfg(test)]
#[error("Test failed: {0}")]
Test(String),
#[error("Failed to write or read: {0}")]
Io(#[from] std::io::Error),
}
impl From<quilt_rs::Error> for Error {
fn from(err: quilt_rs::Error) -> Error {
Error::Quilt(err)
}
}
impl From<quilt_uri::UriError> for Error {
fn from(err: quilt_uri::UriError) -> Error {
Error::Quilt(quilt_rs::Error::Uri(err))
}
}
impl Error {
pub fn kind(&self) -> &'static str {
match self {
Error::Domain => "domain",
Error::Home => "home",
Error::HomeInUse { .. } => "home_in_use",
Error::HomeTwice => "home_twice",
Error::PackageBusy(_) => "package_busy",
Error::Quilt(err) => match err {
quilt_rs::Error::Uri(_) => "invalid_uri",
quilt_rs::Error::Auth(..) => "auth",
quilt_rs::Error::Login(_) => "login",
quilt_rs::Error::Lineage(_) => "lineage",
quilt_rs::Error::WorkflowValidation(_) => "workflow_validation",
_ => "quilt_error",
},
Error::LoginRequired(_) => "login_required",
Error::NamespaceNotFound(_) => "namespace_not_found",
Error::NamespaceRequired => "namespace_required",
Error::CommitMetaInvalid(_) => "commit_meta_invalid",
Error::WorkflowEmpty => "workflow_empty",
Error::WorkflowRequiresBucket => "workflow_requires_bucket",
Error::LogEnv(_) => "invalid_log_filter",
Error::Json(_) => "invalid_json",
#[cfg(test)]
Error::Test(_) => "internal",
Error::Io(_) => "io",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use quilt_uri::fixtures;
use test_log::test;
use crate::cli::model::create_model_in_temp_dir;
use crate::cli::model::install_package_into_temp_dir;
#[test(tokio::test)]
async fn a_writer_waiting_on_a_held_package_says_so() -> Result<(), Error> {
let printed = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
notice_lock_waits({
let printed = std::sync::Arc::clone(&printed);
move |line| printed.lock().expect("printed").push(line.to_string())
});
let (m, _temp_dir) = create_model_in_temp_dir().await?;
let namespace: Namespace = ("test", "held").into();
let created = m
.create(create::Input {
namespace: namespace.clone(),
source: None,
message: None,
})
.await?;
let held = created.installed_package.lock().await?;
let mut uninstall = std::pin::pin!(m.uninstall(uninstall::Input {
namespace,
prune: false,
}));
let early =
tokio::time::timeout(std::time::Duration::from_millis(300), &mut uninstall).await;
assert!(early.is_err(), "the uninstall waits for the holder");
assert_eq!(*printed.lock().expect("printed"), vec![WAITING_NOTICE]);
drop(held);
tokio::time::timeout(std::time::Duration::from_secs(10), uninstall)
.await
.expect("the uninstall runs once the holder lets go")?;
assert_eq!(printed.lock().expect("printed").len(), 1, "said once");
Ok(())
}
#[test]
fn login_required_hint_names_the_binary_not_the_crate() {
let message = Error::LoginRequired(fixtures::host()).to_string();
assert!(
message.contains("> quilt login --host quilt.test"),
"hint should name the `quilt` binary: {message}"
);
assert!(
!message.contains("quilt_rs login"),
"hint must not name the library crate: {message}"
);
}
#[test]
fn error_kinds_are_stable() {
let cases: Vec<(Error, &str)> = vec![
(Error::Domain, "domain"),
(Error::Home, "home"),
(
Error::HomeInUse {
reason: "busy".to_string(),
retry: home::Retry::OverwriteFlag,
},
"home_in_use",
),
(Error::HomeTwice, "home_twice"),
(Error::NamespaceRequired, "namespace_required"),
(Error::WorkflowEmpty, "workflow_empty"),
(Error::WorkflowRequiresBucket, "workflow_requires_bucket"),
(
Error::CommitMetaInvalid("[]".to_string()),
"commit_meta_invalid",
),
(Error::Test("probe".to_string()), "internal"),
(
Error::NamespaceNotFound(("demo", "sales").into()),
"namespace_not_found",
),
(Error::LoginRequired(fixtures::host()), "login_required"),
(
Error::Json(
serde_json::from_str::<serde_json::Value>("{").expect_err("malformed JSON"),
),
"invalid_json",
),
(Error::Io(std::io::Error::other("boom")), "io"),
(
Error::Quilt(quilt_rs::Error::Uri(quilt_uri::UriError::Package(
"bad".to_string(),
))),
"invalid_uri",
),
(
Error::Quilt(quilt_rs::Error::Lineage(quilt_rs::LineageError::Missing)),
"lineage",
),
(
Error::Quilt(quilt_rs::Error::Auth(
fixtures::host(),
quilt_rs::AuthError::TokensRead("boom".to_string()),
)),
"auth",
),
(
Error::Quilt(quilt_rs::Error::Login(quilt_rs::LoginError::NoSession(
None,
))),
"login",
),
(
Error::Quilt(quilt_rs::Error::WorkflowValidation(
quilt_rs::WorkflowValidationError::Rejected(
quilt_rs::workflow::RuleViolation::WorkflowRequired.into(),
),
)),
"workflow_validation",
),
(Error::Quilt(quilt_rs::Error::Unimplemented), "quilt_error"),
];
for (err, expected) in cases {
assert_eq!(err.kind(), expected, "kind for {err:?}");
}
}
#[test]
fn commit_workflow_intent_omit_maps_to_bucket_default() {
assert_eq!(
commit_workflow_intent(None, false).unwrap(),
WorkflowIntent::BucketDefault
);
}
#[test]
fn commit_workflow_intent_no_workflow_flag_opts_out() {
assert_eq!(
commit_workflow_intent(None, true).unwrap(),
WorkflowIntent::NoWorkflow
);
}
#[test]
fn commit_workflow_intent_named_id_maps_to_named() {
assert_eq!(
commit_workflow_intent(Some("x"), false).unwrap(),
WorkflowIntent::Named("x".to_string())
);
}
#[test]
fn commit_workflow_intent_blank_id_is_rejected() {
assert!(matches!(
commit_workflow_intent(Some(""), false),
Err(Error::WorkflowEmpty)
));
assert!(matches!(
commit_workflow_intent(Some(" "), false),
Err(Error::WorkflowEmpty)
));
}
#[test]
fn role_set_flag_is_parsed() {
let listing = Args::try_parse_from(["quilt", "role", "--host", "quilt.test"]).unwrap();
assert!(matches!(listing.command, Commands::Role { set: None, .. }));
let switching =
Args::try_parse_from(["quilt", "role", "--host", "quilt.test", "--set", "ReadOnly"])
.unwrap();
let Commands::Role { host, set } = switching.command else {
panic!("expected the role command");
};
assert_eq!(host.to_string(), "quilt.test");
assert_eq!(set.as_deref(), Some("ReadOnly"));
}
#[test]
fn push_host_flag_is_parsed() -> Result<(), Error> {
let args = Args::try_parse_from([
"quilt",
"push",
"--namespace",
"foo/bar",
"--bucket",
"some-bucket",
"--host",
"quilt.test",
])
.unwrap();
let Commands::Push { host, origin, .. } = args.command else {
panic!("expected the push command");
};
let mut stderr = Vec::new();
let chosen = push_host(host, origin, &mut stderr)?;
assert_eq!(chosen.unwrap().to_string(), "quilt.test");
assert_eq!(String::from_utf8(stderr).unwrap(), "");
Ok(())
}
#[test]
fn push_origin_flag_still_works_and_warns() -> Result<(), Error> {
for spelling in ["--origin", "-o"] {
let args = Args::try_parse_from([
"quilt",
"push",
"--namespace",
"foo/bar",
"--bucket",
"some-bucket",
spelling,
"quilt.test",
])
.unwrap();
let Commands::Push { host, origin, .. } = args.command else {
panic!("expected the push command");
};
let mut stderr = Vec::new();
let chosen = push_host(host, origin, &mut stderr)?;
assert_eq!(chosen.unwrap().to_string(), "quilt.test");
assert_eq!(
String::from_utf8(stderr).unwrap(),
"warning: --origin is deprecated; use --host\n",
"{spelling} warns"
);
}
Ok(())
}
#[test]
fn push_host_and_origin_together_are_rejected() {
let err = Args::try_parse_from([
"quilt",
"push",
"--bucket",
"some-bucket",
"--host",
"one.quilt.test",
"--origin",
"another.quilt.test",
])
.unwrap_err();
assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
}
#[test]
fn push_bucket_and_host_require_each_other() {
for argv in [
&["quilt", "push", "--bucket", "some-bucket"][..],
&["quilt", "push", "--host", "quilt.test"][..],
&["quilt", "push", "--origin", "quilt.test"][..],
] {
let err = Args::try_parse_from(argv).unwrap_err();
assert_eq!(
err.kind(),
clap::error::ErrorKind::MissingRequiredArgument,
"{argv:?}"
);
}
}
#[test]
fn verbose_flag_is_global() {
let before_subcommand = Args::try_parse_from(["quilt", "--verbose", "list"]).unwrap();
assert!(before_subcommand.verbose);
let after_subcommand = Args::try_parse_from(["quilt", "list", "--verbose"]).unwrap();
assert!(after_subcommand.verbose);
let default = Args::try_parse_from(["quilt", "list"]).unwrap();
assert!(!default.verbose);
}
fn ours_at(level: &str) -> String {
format!("quilt={level},quilt_rs={level},quilt_uri={level},quilt_sync={level},warn")
}
fn log_flag(argv: &[&str]) -> Result<Option<String>, String> {
Args::try_parse_with_env(argv)
.map(|args| args.log_directives())
.map_err(|err| err.to_string())
}
#[test]
fn log_flag_goes_through_the_shared_rule_on_either_side_of_the_command() {
assert_eq!(log_flag(&["quilt", "list"]), Ok(None));
assert_eq!(
log_flag(&["quilt", "--log", "debug", "list"]),
Ok(Some(ours_at("debug")))
);
assert_eq!(
log_flag(&["quilt", "list", "--log", "error"]),
Ok(Some("error".to_string()))
);
assert_eq!(
log_flag(&["quilt", "list", "--log", "quilt_rs=trace,hyper=debug"]),
Ok(Some("quilt_rs=trace,hyper=debug".to_string()))
);
}
#[test]
fn verbose_is_log_info() {
for argv in [["quilt", "-v", "list"], ["quilt", "list", "--verbose"]] {
assert_eq!(log_flag(&argv), Ok(Some(ours_at("info"))), "{argv:?}");
}
}
#[test]
fn verbose_with_log_is_an_error() {
for argv in [
["quilt", "-v", "list", "--log", "debug"],
["quilt", "list", "--log", "info", "--verbose"],
] {
let result = log_flag(&argv);
assert!(
result
.as_ref()
.is_err_and(|err| err.contains("cannot be used with")),
"{argv:?}: {result:?}"
);
}
}
#[test]
fn a_log_flag_that_does_not_parse_is_an_error() {
for value in ["", " ", "debgu", "quilt_rs", "quilt_rs=lots"] {
let result = log_flag(&["quilt", "list", "--log", value]);
assert!(
result
.as_ref()
.is_err_and(|err| err.contains("for '--log <FILTER>'")),
"{value:?}: {result:?}"
);
}
}
#[test]
fn uninstall_prunes_only_when_asked() {
let plain = Args::try_parse_from(["quilt", "uninstall", "-n", "a/b"]).expect("parses");
assert!(matches!(
plain.command,
Commands::Uninstall { prune: false, .. }
));
let pruning =
Args::try_parse_from(["quilt", "uninstall", "-n", "a/b", "--prune"]).expect("parses");
assert!(matches!(
pruning.command,
Commands::Uninstall { prune: true, .. }
));
}
#[test]
fn remove_revisions_count_is_optional_and_at_least_one() {
let all = Args::try_parse_from(["quilt", "remove-revisions", "-n", "a/b"]).expect("parses");
assert!(matches!(
all.command,
Commands::RemoveRevisions { count: None, .. }
));
let two = Args::try_parse_from(["quilt", "remove-revisions", "-n", "a/b", "-c", "2"])
.expect("parses");
assert!(matches!(
two.command,
Commands::RemoveRevisions { count: Some(count), .. } if count.get() == 2
));
let err = Args::try_parse_from(["quilt", "remove-revisions", "--count", "0"])
.expect_err("0 is refused");
assert_eq!(err.kind(), clap::error::ErrorKind::ValueValidation);
assert!(err.to_string().contains("must be at least 1"), "{err}");
}
#[test]
fn json_flag_parses_before_or_after_the_subcommand() {
let after = Args::try_parse_from(["quilt", "list", "--json"]).expect("parses");
assert!(after.json);
assert!(matches!(after.command, Commands::List { fetch: false }));
let before = Args::try_parse_from(["quilt", "--json", "list"]).expect("parses");
assert!(before.json);
assert!(matches!(before.command, Commands::List { fetch: false }));
let status = Args::try_parse_from(["quilt", "status", "-n", "demo/sales", "--json"])
.expect("parses");
assert!(status.json);
let default = Args::try_parse_from(["quilt", "list"]).expect("parses");
assert!(!default.json);
}
#[test]
fn domain_flag_parses_before_or_after_the_subcommand() {
let domain = Some(PathBuf::from("/tmp/quilt-domain"));
for argv in [
["quilt", "--domain", "/tmp/quilt-domain", "list"],
["quilt", "-d", "/tmp/quilt-domain", "list"],
["quilt", "list", "--domain", "/tmp/quilt-domain"],
["quilt", "list", "-d", "/tmp/quilt-domain"],
] {
let args = Args::try_parse_from(argv).expect("parses");
assert_eq!(args.domain, domain, "{argv:?}");
assert!(
matches!(args.command, Commands::List { fetch: false }),
"{argv:?}"
);
}
let status = Args::try_parse_from([
"quilt",
"status",
"-n",
"demo/sales",
"--domain",
"/tmp/quilt-domain",
])
.expect("parses");
assert_eq!(status.domain, domain);
assert!(matches!(
status.command,
Commands::Status {
pkg: PackageRef { namespace: Some(namespace) },
} if namespace == "demo/sales"
));
}
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq)]
struct Parsed {
domain: Result<PathBuf, String>,
json: bool,
}
fn parse_under_env(argv: &[&str], vars: &[(&str, Option<&str>)]) -> Result<Parsed, String> {
let mut child = std::process::Command::new(std::env::current_exe().unwrap());
child
.args(["cli::tests::env_child", "--exact", "--nocapture"])
.env(ENV_CHILD_ARGV, argv.join("\u{1f}"));
for env in [DOMAIN_ENV, FORMAT_ENV] {
match vars.iter().find(|(name, _)| *name == env) {
Some((_, Some(value))) => child.env(env, value),
_ => child.env_remove(env),
};
}
let output = child.output().expect("spawn the parser");
let stdout = String::from_utf8_lossy(&output.stdout);
let line = stdout
.lines()
.find_map(|line| line.strip_prefix(ENV_CHILD_MARKER))
.unwrap_or_else(|| panic!("the child printed no result: {stdout}"));
serde_json::from_str::<Result<Parsed, String>>(line).expect("the child's result")
}
fn parse_domain_under_env(argv: &[&str], value: Option<&str>) -> Result<PathBuf, String> {
parse_under_env(argv, &[(DOMAIN_ENV, value)]).and_then(|parsed| parsed.domain)
}
fn parse_format_under_env(argv: &[&str], value: Option<&str>) -> Result<Format, String> {
parse_under_env(argv, &[(FORMAT_ENV, value)]).map(|parsed| {
if parsed.json {
Format::Json
} else {
Format::Text
}
})
}
const ENV_CHILD_ARGV: &str = "QUILT_TEST_ENV_ARGV";
const ENV_CHILD_MARKER: &str = "env-child-result:";
#[test]
fn env_child() {
let Ok(argv) = std::env::var(ENV_CHILD_ARGV) else {
return;
};
let result = Args::try_parse_with_env(argv.split('\u{1f}'))
.map_err(|err| err.to_string())
.map(|args| Parsed {
json: args.format() == Format::Json,
domain: get_domain_dir(args.domain).map_err(|err| err.to_string()),
});
println!(
"{ENV_CHILD_MARKER}{}",
serde_json::to_string(&result).unwrap()
);
}
#[test]
fn domain_env_is_used_without_the_flag() {
let domain = PathBuf::from("/tmp/quilt-env-domain");
assert_eq!(
parse_domain_under_env(&["quilt", "list"], Some("/tmp/quilt-env-domain")),
Ok(domain.clone())
);
assert_eq!(
parse_domain_under_env(
&["quilt", "status", "-n", "demo/sales"],
Some("/tmp/quilt-env-domain")
),
Ok(domain)
);
}
#[test]
fn domain_flag_beats_domain_env() {
let flag = PathBuf::from("/tmp/quilt-flag-domain");
for argv in [
["quilt", "--domain", "/tmp/quilt-flag-domain", "list"],
["quilt", "list", "-d", "/tmp/quilt-flag-domain"],
] {
assert_eq!(
parse_domain_under_env(&argv, Some("/tmp/quilt-env-domain")),
Ok(flag.clone()),
"{argv:?}"
);
}
}
#[test]
fn empty_domain_env_falls_back_to_the_default() {
let default = get_domain_dir(None).map_err(|err| err.to_string());
assert_eq!(parse_domain_under_env(&["quilt", "list"], None), default);
assert_eq!(
parse_domain_under_env(&["quilt", "list"], Some("")),
default
);
assert_eq!(
parse_domain_under_env(&["quilt", "list", "-d", "/tmp/quilt-flag-domain"], Some("")),
Ok(PathBuf::from("/tmp/quilt-flag-domain"))
);
}
#[test]
fn empty_domain_flag_is_an_error() {
for value in [None, Some(""), Some("/tmp/quilt-env-domain")] {
let result = parse_domain_under_env(&["quilt", "list", "--domain", ""], value);
assert!(
result
.as_ref()
.is_err_and(|err| err.contains("a value is required for '--domain")),
"{value:?}: {result:?}"
);
}
}
#[test]
fn help_names_the_domain_env() {
for value in [None, Some(""), Some("/tmp/quilt-env-domain")] {
let help = parse_domain_under_env(&["quilt", "--help"], value).unwrap_err();
assert!(help.contains("[env: QUILT_DOMAIN"), "{value:?}: {help}");
}
}
#[test]
fn format_example() {
assert_eq!(
parse_format_under_env(&["quilt", "list"], Some("json")),
Ok(Format::Json)
);
assert_eq!(
parse_format_under_env(&["quilt", "list", "--format", "text"], Some("json")),
Ok(Format::Text)
);
assert_eq!(
parse_format_under_env(&["quilt", "list", "--json"], None),
Ok(Format::Json)
);
let conflict =
parse_format_under_env(&["quilt", "list", "--json", "--format", "text"], None);
assert!(
conflict
.as_ref()
.is_err_and(|err| err.contains("cannot be used with")),
"{conflict:?}"
);
}
#[test]
fn format_defaults_to_text() {
assert_eq!(
parse_format_under_env(&["quilt", "list"], None),
Ok(Format::Text)
);
}
#[test]
fn format_env_is_used_without_the_flag() {
for (value, format) in [("json", Format::Json), ("text", Format::Text)] {
assert_eq!(
parse_format_under_env(&["quilt", "list"], Some(value)),
Ok(format)
);
assert_eq!(
parse_format_under_env(&["quilt", "status", "-n", "demo/sales"], Some(value)),
Ok(format)
);
}
}
#[test]
fn format_flag_beats_format_env() {
for (argv, env, format) in [
(["quilt", "--format", "json", "list"], "text", Format::Json),
(["quilt", "list", "--format", "json"], "text", Format::Json),
(["quilt", "--format", "text", "list"], "json", Format::Text),
(["quilt", "list", "--format", "text"], "json", Format::Text),
] {
assert_eq!(
parse_format_under_env(&argv, Some(env)),
Ok(format),
"{argv:?} with {env}"
);
}
}
#[test]
fn json_flag_still_parses_before_and_after_the_command() {
for argv in [["quilt", "--json", "list"], ["quilt", "list", "--json"]] {
assert_eq!(
parse_format_under_env(&argv, None),
Ok(Format::Json),
"{argv:?}"
);
}
}
#[test]
fn json_flag_beats_format_env() {
for argv in [["quilt", "--json", "list"], ["quilt", "list", "--json"]] {
assert_eq!(
parse_format_under_env(&argv, Some("text")),
Ok(Format::Json),
"{argv:?}"
);
}
}
#[test]
fn json_flag_with_format_flag_is_an_error() {
for argv in [
["quilt", "list", "--json", "--format", "text"],
["quilt", "--format", "text", "list", "--json"],
["quilt", "list", "--json", "--format", "json"],
] {
let result = parse_format_under_env(&argv, None);
assert!(
result
.as_ref()
.is_err_and(|err| err.contains("cannot be used with")),
"{argv:?}: {result:?}"
);
}
}
#[test]
fn empty_format_env_falls_back_to_text() {
assert_eq!(
parse_format_under_env(&["quilt", "list"], Some("")),
Ok(Format::Text)
);
assert_eq!(
parse_format_under_env(&["quilt", "list", "--format", "json"], Some("")),
Ok(Format::Json)
);
assert_eq!(
parse_format_under_env(&["quilt", "list", "--json"], Some("")),
Ok(Format::Json)
);
}
#[test]
fn empty_format_flag_is_an_error() {
for value in [None, Some(""), Some("json")] {
let result = parse_format_under_env(&["quilt", "list", "--format", ""], value);
assert!(
result
.as_ref()
.is_err_and(|err| err.contains("a value is required for '--format")),
"{value:?}: {result:?}"
);
}
}
#[test]
fn unknown_format_is_an_error() {
for (argv, value) in [
(&["quilt", "list"][..], Some("yaml")),
(&["quilt", "list", "--json"][..], Some("yaml")),
(&["quilt", "list", "--format", "yaml"][..], None),
] {
let result = parse_format_under_env(argv, value);
assert!(
result
.as_ref()
.is_err_and(|err| err.contains("invalid value 'yaml' for '--format")),
"{argv:?} with {value:?}: {result:?}"
);
}
}
#[test]
fn help_names_the_format_env() {
for value in [None, Some(""), Some("json")] {
let help = parse_format_under_env(&["quilt", "--help"], value).unwrap_err();
assert!(help.contains("[env: QUILT_FORMAT"), "{value:?}: {help}");
assert!(help.contains("result"), "{value:?}: {help}");
}
}
#[test]
fn package_namespace_flag_is_optional() {
let inferred = Args::try_parse_from(["quilt", "status"]).unwrap();
assert!(matches!(
inferred.command,
Commands::Status {
pkg: PackageRef { namespace: None },
}
));
let explicit =
Args::try_parse_from(["quilt", "status", "--namespace", "demo/sales"]).unwrap();
assert!(matches!(
explicit.command,
Commands::Status {
pkg: PackageRef { namespace: Some(namespace) },
} if namespace == "demo/sales"
));
}
#[test]
fn namespace_from_working_dir_uses_package_components() -> Result<(), Error> {
let home = Path::new("/home/user/QuiltSync");
let current_dir = Path::new("/home/user/QuiltSync/demo/sales/src");
assert_eq!(
namespace_from_working_dir(home, current_dir)?,
Namespace::from(("demo", "sales"))
);
Ok(())
}
#[test]
fn namespace_from_working_dir_requires_a_package_path() {
let home = Path::new("/home/user/QuiltSync");
assert!(matches!(
namespace_from_working_dir(home, Path::new("/home/user/QuiltSync")),
Err(Error::NamespaceRequired)
));
assert!(matches!(
namespace_from_working_dir(home, Path::new("/home/user/other/demo/sales")),
Err(Error::NamespaceRequired)
));
}
#[test]
fn test_parse_optional_namespace() -> Result<(), Error> {
assert!(parse_optional_namespace(None)?.is_none());
let ns = parse_optional_namespace(Some("foo/bar".to_string()))?.unwrap();
assert_eq!(ns.to_string(), "foo/bar");
let err = parse_optional_namespace(Some("invalid".to_string())).unwrap_err();
assert!(matches!(err, Error::Quilt(_)));
Ok(())
}
#[test]
fn test_get_domain_dir() -> Result<(), Error> {
let test_dir = PathBuf::from("/test/path");
assert_eq!(get_domain_dir(Some(test_dir.clone()))?, test_dir);
if let Some(local_dir) = dirs::data_local_dir() {
let expected = local_dir.join(DOMAIN_DIR_NAMESPACE);
assert_eq!(get_domain_dir(None)?, expected);
} else {
assert!(matches!(get_domain_dir(None), Err(Error::Domain)));
}
Ok(())
}
#[test]
fn default_home_dir_is_quiltsync_under_the_user_home() {
assert_eq!(
default_home_dir_under(Some(PathBuf::from("/home/ann"))).unwrap(),
PathBuf::from("/home/ann/QuiltSync")
);
assert!(matches!(default_home_dir_under(None), Err(Error::Home)));
}
#[test]
fn tests_never_use_the_real_default_home() {
let real = dirs::home_dir().map(|home| home.join(quilt_rs::DEFAULT_HOME_DIR_NAME));
assert_ne!(get_default_home_dir().ok(), real);
}
#[test(tokio::test)]
async fn commands_without_a_home_leave_a_new_domain_without_one() -> Result<(), Error> {
let commands = [
Commands::Login {
code: None,
host: fixtures::host(),
},
Commands::Browse {
uri: "not-a-package-uri".to_string(),
},
];
for command in commands {
let domain = tempfile::tempdir()?;
let args = Args {
home: None,
domain: Some(domain.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command,
};
let _ = init(args).await;
let stored = quilt_rs::LocalDomain::new(domain.path()).get_home().await;
assert!(
matches!(
stored,
Err(quilt_rs::Error::Lineage(quilt_rs::LineageError::Missing))
),
"no home is stored: {stored:?}"
);
}
Ok(())
}
#[test(tokio::test)]
async fn home_flag_still_sets_the_home_before_login() -> Result<(), Error> {
let domain = tempfile::tempdir()?;
let home = tempfile::tempdir()?;
let args = Args {
home: Some(home.path().to_path_buf()),
domain: Some(domain.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Login {
code: None,
host: fixtures::host(),
},
};
init(args).await?;
let stored = quilt_rs::LocalDomain::new(domain.path()).get_home().await?;
assert_eq!(stored.as_ref(), home.path());
Ok(())
}
#[test(tokio::test)]
async fn test_list_uses_default_home_without_flag() -> Result<(), Error> {
let domain_temp_dir = tempfile::tempdir()?;
let list_args = Args {
home: None,
domain: Some(domain_temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::List { fetch: false },
};
let mut output = Vec::new();
let result = init(list_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
assert_eq!(
String::from_utf8(output).unwrap(),
"No installed packages\n"
);
let stored_home = quilt_rs::LocalDomain::new(domain_temp_dir.path())
.get_home()
.await?;
assert_eq!(stored_home.as_ref(), &get_default_home_dir()?);
Ok(())
}
#[test(tokio::test)]
async fn test_missing_home_lineage_is_repaired_without_flag() -> Result<(), Error> {
let (model, domain_temp_dir) = Model::from_temp_dir()?;
let paths = quilt_rs::paths::DomainPaths::new(domain_temp_dir.path().to_path_buf());
std::fs::create_dir_all(paths.dot_quilt_dir())?;
std::fs::write(paths.lineage(), br#"{"packages":{},"home":""}"#)?;
initialize_home(&model, None, &mut Vec::new()).await?;
assert_eq!(model.get_home().await?.as_ref(), &get_default_home_dir()?);
Ok(())
}
#[test(tokio::test)]
async fn test_existing_home_is_preserved_without_flag() -> Result<(), Error> {
let (model, _domain_temp_dir) = Model::from_temp_dir()?;
let home_temp_dir = tempfile::tempdir()?;
model.set_home(home_temp_dir.path()).await?;
initialize_home(&model, None, &mut Vec::new()).await?;
assert_eq!(
model.get_home().await?.as_ref(),
&home_temp_dir.path().to_path_buf()
);
Ok(())
}
#[test(tokio::test)]
async fn live_install() -> Result<(), Error> {
use crate::cli::fixtures::packages::workflow_null as pkg;
let domain_temp_dir = tempfile::tempdir()?;
let domain = Some(domain_temp_dir.path().to_path_buf());
let working_temp_dir = tempfile::tempdir()?;
let home = Some(working_temp_dir.path().to_path_buf());
let install_args = Args {
home,
domain,
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Install {
namespace: Some(Namespace::from(pkg::NAMESPACE).to_string()),
uri: pkg::URI.to_string(),
path: None,
},
};
let mut output = Vec::new();
let result = init(install_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"Installed package \"{}\"\nNo paths installed\n",
pkg::NAMESPACE_STR,
)
);
Ok(())
}
#[test(tokio::test)]
async fn live_commit_valid() -> Result<(), Error> {
use crate::cli::fixtures::packages::workflow_null as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let commit_args = Args {
home: Some(temp_dir.path().to_path_buf()),
domain: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Commit {
message: pkg::MESSAGE.to_string(),
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
user_meta: None,
workflow: None,
no_workflow: true,
},
};
let mut output = Vec::new();
let result = init(commit_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
"New commit \"095017e53f4c8e0a07c82e562d088aa0e0f7a9ecaf2dce74a7607fac9085e98f\" created\n".to_string()
);
Ok(())
}
#[test(tokio::test)]
async fn live_commit_invalid() -> Result<(), Error> {
use crate::cli::fixtures::packages::workflow_null as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let commit_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Commit {
message: "Any message".to_string(),
pkg: PackageRef {
namespace: Some("in/valid".to_string()),
},
user_meta: None,
workflow: None,
no_workflow: true,
},
};
let mut output = Vec::new();
let result = init(commit_args).await?;
print(result, Format::Text, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "Package in/valid not found\n".to_string());
Ok(())
}
#[test(tokio::test)]
async fn test_push_workflow_without_bucket_errors() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let push_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Push {
pkg: PackageRef {
namespace: Some("foo/bar".to_string()),
},
bucket: None,
host: None,
origin: None,
workflow: Some("x".to_string()),
no_workflow: false,
},
};
assert!(matches!(
init(push_args).await,
Err(Error::WorkflowRequiresBucket)
));
Ok(())
}
#[test(tokio::test)]
async fn test_push_no_workflow_without_bucket_errors() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let push_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Push {
pkg: PackageRef {
namespace: Some("foo/bar".to_string()),
},
bucket: None,
host: None,
origin: None,
workflow: None,
no_workflow: true,
},
};
assert!(matches!(
init(push_args).await,
Err(Error::WorkflowRequiresBucket)
));
Ok(())
}
#[test(tokio::test)]
async fn test_push_workflow_with_bucket_is_accepted() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let push_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Push {
pkg: PackageRef {
namespace: Some("foo/bar".to_string()),
},
bucket: Some("some-bucket".to_string()),
host: Some(fixtures::host()),
origin: None,
workflow: Some("x".to_string()),
no_workflow: false,
},
};
let mut output = Vec::new();
let result = init(push_args).await?;
print(result, Format::Text, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "Package foo/bar not found\n");
Ok(())
}
#[test(tokio::test)]
async fn live_pull_valid() -> Result<(), Error> {
use crate::cli::fixtures::packages::outdated as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"Revision \"{}\" pulled\nLatest revision: Today's Date: 2024-07-29 11:53:53\n",
pkg::LATEST_TOP_HASH
)
);
Ok(())
}
#[test(tokio::test)]
async fn live_pull_reports_what_the_revision_brought() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::R1_URI).await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
concat!(
"Revision \"{}\" pulled\n",
"7 files new, not downloaded:\n",
" add/deeply/nested/directory/with/a/very-long-name/summary-of-everything.parquet\n",
" add/five.txt\n",
" add/four.txt\n",
" add/one.txt\n",
" add/six.txt\n",
" add/three.txt\n",
" add/two.txt\n",
"Latest revision: {}\n",
),
pkg::R3_TOP_HASH,
pkg::R3_MESSAGE,
)
);
let groups = output_str
.split("Latest revision:")
.next()
.expect("split always yields one part");
for silent in ["modify.txt", "keep.txt", "remove.txt"] {
assert!(
!groups.contains(silent),
"{silent} changed on the remote but nothing moved here, so no group may name it"
);
}
Ok(())
}
#[test(tokio::test)]
async fn live_pull_reports_updates_and_removals_for_tracked_paths() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
use crate::cli::model::install_paths_into_temp_dir;
let tracked = ["keep.txt", "modify.txt", "remove.txt"]
.iter()
.map(std::path::PathBuf::from)
.collect();
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
concat!(
"Revision \"{}\" pulled\n",
"7 files new, not downloaded:\n",
" add/deeply/nested/directory/with/a/very-long-name/summary-of-everything.parquet\n",
" add/five.txt\n",
" add/four.txt\n",
" add/one.txt\n",
" add/six.txt\n",
" add/three.txt\n",
" add/two.txt\n",
"2 files updated:\n",
" keep.txt\n",
" modify.txt\n",
"1 file removed:\n",
" remove.txt\n",
"Latest revision: {}\n",
),
pkg::R3_TOP_HASH,
pkg::R3_MESSAGE,
)
);
Ok(())
}
fn mapped_reader_child(path: &str, len: usize) {
use std::os::unix::io::AsRawFd as _;
let file = std::fs::File::open(path).expect("map target");
let addr = unsafe {
libc::mmap(
std::ptr::null_mut(),
len,
libc::PROT_READ,
libc::MAP_PRIVATE,
file.as_raw_fd(),
0,
)
};
assert!(!std::ptr::eq(addr, libc::MAP_FAILED), "mmap failed");
println!("MAPPED");
let mut line = String::new();
std::io::BufRead::read_line(&mut std::io::stdin().lock(), &mut line).ok();
let byte = unsafe { std::ptr::read_volatile((addr as *const u8).add(len - 1)) };
println!("READ {byte}");
}
#[test(tokio::test)]
#[allow(
clippy::zombie_processes,
reason = "the child is killed and reaped on every path out, including a failed pull and each failed assertion; the analysis cannot follow it through the branches"
)]
async fn live_pull_leaves_a_memory_mapped_file_readable() -> Result<(), Error> {
use crate::cli::fixtures::packages::shrinking as pkg;
use crate::cli::model::install_paths_into_temp_dir;
const CHILD_ENV: &str = "QUILT_MMAP_TEST_FILE";
if let Ok(path) = std::env::var(CHILD_ENV) {
mapped_reader_child(&path, pkg::R1_LEN);
return Ok(());
}
let tracked = vec![std::path::PathBuf::from(pkg::MAPPED)];
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let root = temp_dir.path().to_path_buf();
let mapped = root.join(pkg::NAMESPACE_STR).join(pkg::MAPPED);
assert_eq!(
std::fs::metadata(&mapped).unwrap().len(),
pkg::R1_LEN as u64,
"r1 should be installed at its full size"
);
let mut kid = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"cli::tests::live_pull_leaves_a_memory_mapped_file_readable",
"--exact",
"--nocapture",
])
.env(CHILD_ENV, &mapped)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()
.expect("spawn the reader");
fn wait_for(out: &mut impl std::io::BufRead, marker: &str) -> Option<String> {
let mut line = String::new();
while {
line.clear();
std::io::BufRead::read_line(out, &mut line).unwrap_or(0) > 0
} {
if line.starts_with(marker) {
return Some(line.trim_end().to_string());
}
}
None
}
let mut out = std::io::BufReader::new(kid.stdout.take().unwrap());
let mapped_ok = wait_for(&mut out, "MAPPED").is_some();
if !mapped_ok {
kid.kill().ok();
kid.wait().ok();
panic!("the child never reported a mapping");
}
let pull = Args {
domain: Some(root.clone()),
home: Some(root.clone()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let pulled = async {
let result = init(pull).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
Ok::<_, Error>(())
}
.await;
if pulled.is_err() {
kid.kill().ok();
kid.wait().ok();
}
pulled?;
let report = String::from_utf8(output).unwrap();
let landed_r2 = report.contains(pkg::R2_TOP_HASH);
let shrank = std::fs::metadata(&mapped).unwrap().len() < pkg::R1_LEN as u64;
if !(landed_r2 && shrank) {
kid.kill().ok();
kid.wait().ok();
assert!(landed_r2, "the pull should land r2, got: {report}");
assert!(shrank, "r2 should be the shorter file");
}
use std::io::Write as _;
kid.stdin.take().unwrap().write_all(b"\n").ok();
let read = wait_for(&mut out, "READ");
let status = kid.wait().expect("reader should be reapable");
assert!(
read.is_some(),
"the mapped reader never completed its read — the pull killed it: {status:?}"
);
assert!(
status.success(),
"the mapped reader did not exit cleanly: {status:?}"
);
Ok(())
}
#[test(tokio::test)]
async fn live_killed_pull_leaves_every_tracked_path_at_the_old_revision() -> Result<(), Error> {
use crate::cli::fixtures::packages::large as pkg;
use crate::cli::model::install_paths_into_temp_dir;
const CHILD_ENV: &str = "QUILT_KILLED_PULL_DOMAIN";
fn pull_args(root: &std::path::Path) -> Args {
Args {
domain: Some(root.to_path_buf()),
home: Some(root.to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
}
}
if let Ok(root) = std::env::var(CHILD_ENV) {
let _ = init(pull_args(std::path::Path::new(&root))).await;
return Ok(());
}
let tracked = pkg::PATHS
.iter()
.map(std::path::PathBuf::from)
.collect::<Vec<_>>();
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let root = temp_dir.path().to_path_buf();
let working = |name: &str| root.join(pkg::NAMESPACE_STR).join(name);
let at_r1: Vec<Vec<u8>> = pkg::PATHS
.iter()
.map(|name| std::fs::read(working(name)).expect("installed at r1"))
.collect();
let mut kid = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"cli::tests::live_killed_pull_leaves_every_tracked_path_at_the_old_revision",
"--exact",
"--nocapture",
])
.env(CHILD_ENV, &root)
.spawn()
.expect("spawn the pull that gets killed");
let staging = root.join(".quilt").join("staging");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(120);
let mut staged = false;
while std::time::Instant::now() < deadline {
if std::fs::read_dir(&staging).is_ok_and(|entries| {
entries.flatten().any(|run| {
std::fs::read_dir(run.path())
.is_ok_and(|f| f.flatten().count() >= pkg::PATHS.len())
})
}) {
staged = true;
break;
}
if kid.try_wait().ok().flatten().is_some() {
break;
}
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
}
kid.kill().ok();
kid.wait().ok();
assert!(
staged,
"the whole touch set was never staged at once, so either the pull was not interrupted mid-apply or files are being swapped in one at a time"
);
for (name, before) in pkg::PATHS.iter().zip(&at_r1) {
let after = std::fs::read(working(name))
.unwrap_or_else(|err| panic!("{name} is absent after a killed pull: {err:?}"));
assert_eq!(
after.len(),
before.len(),
"{name} changed size, so it holds neither revision whole"
);
assert!(&after == before, "{name} was written before the swap began");
}
let mut output = Vec::new();
let result = init(pull_args(&root)).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let report = String::from_utf8(output).unwrap();
assert!(
report.contains(pkg::R2_TOP_HASH),
"the retry should land r2, got: {report}"
);
for (name, before) in pkg::PATHS.iter().zip(&at_r1) {
let after = std::fs::read(working(name)).expect("present after the retry");
assert!(&after != before, "{name} still holds r1 after the retry");
}
Ok(())
}
#[test(tokio::test)]
async fn live_pull_keeps_local_work_the_remote_did_not_touch() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
use crate::cli::model::install_paths_into_temp_dir;
let tracked = ["add/one.txt", "keep.txt"]
.iter()
.map(std::path::PathBuf::from)
.collect();
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R2_URI, Some(tracked)).await?;
let edited = temp_dir.path().join(pkg::NAMESPACE_STR).join("add/one.txt");
std::fs::write(&edited, "MY LOCAL EDIT\n").expect("the path was just checked out");
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
concat!(
"Revision \"{}\" pulled\n",
"1 file new, not downloaded:\n",
" add/six.txt\n",
"1 file updated:\n",
" keep.txt\n",
"Latest revision: {}\n",
),
pkg::R3_TOP_HASH,
pkg::R3_MESSAGE,
)
);
assert_eq!(
std::fs::read_to_string(&edited).unwrap(),
"MY LOCAL EDIT\n",
"the pull overwrote local work the remote had not touched"
);
Ok(())
}
#[test(tokio::test)]
async fn live_pull_blocked_by_a_conflict_reports_nothing() -> Result<(), Error> {
use crate::cli::fixtures::packages::revision_report as pkg;
use crate::cli::model::install_paths_into_temp_dir;
let tracked = vec![std::path::PathBuf::from("keep.txt")];
let (_, _, temp_dir) = install_paths_into_temp_dir(pkg::R1_URI, Some(tracked)).await?;
let root = temp_dir.path().join(pkg::NAMESPACE_STR).join("add");
std::fs::create_dir_all(&root).expect("the package root exists");
std::fs::write(root.join("one.txt"), "DIFFERENT CONTENT THAN THE REMOTE\n")
.expect("just created the directory");
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
},
};
let mut out = Vec::new();
let mut errs = Vec::new();
let result = init(pull_args).await?;
print(result, Format::Text, &mut out, &mut errs).ok();
let out = String::from_utf8(out).unwrap();
let errs = String::from_utf8(errs).unwrap();
assert!(
errs.contains("add/one.txt"),
"the refusal must name the conflicting path: {errs}"
);
assert!(
out.is_empty(),
"a blocked pull applies nothing and must report nothing, got: {out}"
);
Ok(())
}
#[test(tokio::test)]
async fn test_pull_invalid() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let pull_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Pull {
pkg: PackageRef {
namespace: Some("in/valid".to_string()),
},
},
};
let mut output = Vec::new();
let result = init(pull_args).await?;
print(result, Format::Text, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "Package in/valid not found\n");
Ok(())
}
#[test(tokio::test)]
async fn live_uninstall_valid() -> Result<(), Error> {
use crate::cli::fixtures::packages::default as pkg;
let (_, _, temp_dir) = install_package_into_temp_dir(pkg::URI).await?;
let uninstall_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Uninstall {
pkg: PackageRef {
namespace: Some(pkg::NAMESPACE_STR.to_string()),
},
prune: false,
},
};
let mut output = Vec::new();
let result = init(uninstall_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!("Package {} successfully uninstalled\n", pkg::NAMESPACE_STR)
);
Ok(())
}
#[test(tokio::test)]
async fn test_uninstall_invalid() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let uninstall_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Uninstall {
pkg: PackageRef {
namespace: Some("in/valid".to_string()),
},
prune: false,
},
};
let mut output = Vec::new();
let result = init(uninstall_args).await?;
print(result, Format::Text, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert!(output_str.ends_with("The given package is not installed: in/valid\n"));
Ok(())
}
#[test(tokio::test)]
async fn test_list_invalid() -> Result<(), Error> {
use std::fs::Permissions;
use std::os::unix::fs::PermissionsExt;
use tempfile::Builder;
let write_only = Permissions::from_mode(0o200);
let temp_dir = Builder::new().permissions(write_only).tempdir()?;
let list_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::List { fetch: false },
};
let err = init(list_args).await.unwrap_err();
assert!(err.to_string().contains("Permission denied"));
Ok(())
}
#[test(tokio::test)]
async fn test_list_valid() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let list_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::List { fetch: false },
};
let mut output = Vec::new();
let result = init(list_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, "No installed packages\n");
Ok(())
}
#[test(tokio::test)]
async fn json_mode_emits_one_parseable_object_per_local_command() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let dir = temp_dir.path().to_path_buf();
let args = |command| Args {
domain: Some(dir.clone()),
home: Some(dir.clone()),
verbose: false,
log: None,
format: None,
json: true,
command,
};
let pkg = || PackageRef {
namespace: Some("test/pkg".to_string()),
};
let source = dir.join("source");
std::fs::create_dir_all(&source)?;
std::fs::write(source.join("data.csv"), "a,b\n1,2")?;
let commands = vec![
(
"create",
Commands::Create {
namespace: "test/pkg".to_string(),
source: Some(source.clone()),
message: Some("first".to_string()),
},
),
("list", Commands::List { fetch: false }),
("status", Commands::Status { pkg: pkg() }),
(
"commit",
Commands::Commit {
message: "second".to_string(),
user_meta: None,
pkg: pkg(),
workflow: None,
no_workflow: true,
},
),
("log", Commands::Log { pkg: pkg() }),
("undo-commit", Commands::UndoCommit { pkg: pkg() }),
(
"remove-revisions",
Commands::RemoveRevisions {
pkg: pkg(),
count: None,
},
),
(
"uninstall",
Commands::Uninstall {
pkg: pkg(),
prune: false,
},
),
];
for (name, command) in commands {
if name == "commit" {
std::fs::write(dir.join("test/pkg/data.csv"), "a,b\n3,4")?;
}
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let result = init(args(command)).await?;
print(result, Format::Json, &mut stdout, &mut stderr)?;
let parsed: serde_json::Value = serde_json::from_slice(&stdout).unwrap_or_else(|err| {
panic!(
"`{name} --json` emitted unparseable stdout ({err}): {}",
String::from_utf8_lossy(&stdout)
)
});
assert!(
parsed.is_object(),
"`{name} --json` emitted {parsed}, not a bare object"
);
assert!(
stderr.is_empty(),
"`{name} --json` wrote to stderr: {}",
String::from_utf8_lossy(&stderr)
);
}
Ok(())
}
#[test(tokio::test)]
async fn json_mode_failure_leaves_stdout_empty_and_stderr_parseable() -> Result<(), Error> {
let (_, temp_dir) = create_model_in_temp_dir().await?;
let args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: true,
command: Commands::Status {
pkg: PackageRef {
namespace: Some("no/such".to_string()),
},
},
};
let mut stdout = Vec::new();
let mut stderr = Vec::new();
print(init(args).await?, Format::Json, &mut stdout, &mut stderr)?;
assert!(stdout.is_empty(), "stdout must stay empty on failure");
let parsed: serde_json::Value =
serde_json::from_slice(&stderr).expect("stderr carries parseable JSON");
assert_eq!(parsed["error"]["kind"], "namespace_not_found");
Ok(())
}
#[test(tokio::test)]
async fn test_install_invalid() -> Result<(), Error> {
use crate::cli::fixtures::packages::invalid as pkg;
let temp_dir = tempfile::tempdir()?;
let domain = Some(temp_dir.path().to_path_buf());
let home = domain.clone();
let install_args = Args {
domain,
home,
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Install {
namespace: None,
uri: pkg::URI.to_string(),
path: None,
},
};
let mut output = Vec::new();
let result = init(install_args).await?;
print(result, Format::Text, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"quilt_rs error: Invalid package URI: S3 package URI must contain a fragment: {}\n",
pkg::URI
)
);
Ok(())
}
#[test(tokio::test)]
async fn live_browse_valid() -> Result<(), Error> {
use crate::cli::fixtures::get_browse_output;
use crate::cli::fixtures::packages::default as pkg;
let temp_dir = tempfile::tempdir()?;
let uri = format!("{}&path={}", pkg::URI_LATEST, pkg::README_LK_ESCAPED);
let browse_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Browse { uri },
};
let mut output = Vec::new();
let result = init(browse_args).await?;
print(result, Format::Text, &mut output, &mut Vec::new())?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(output_str, format!("{}\n", get_browse_output()?));
Ok(())
}
#[test(tokio::test)]
async fn test_browse_invalid() -> Result<(), Error> {
use crate::cli::fixtures::packages::invalid as pkg;
let temp_dir = tempfile::tempdir()?;
let browse_args = Args {
domain: Some(temp_dir.path().to_path_buf()),
home: Some(temp_dir.path().to_path_buf()),
verbose: false,
log: None,
format: None,
json: false,
command: Commands::Browse {
uri: pkg::URI.to_string(),
},
};
let mut output = Vec::new();
let result = init(browse_args).await?;
print(result, Format::Text, &mut Vec::new(), &mut output)?;
let output_str = String::from_utf8(output).unwrap();
assert_eq!(
output_str,
format!(
"quilt_rs error: Invalid package URI: S3 package URI must contain a fragment: {}\n",
pkg::URI
)
);
Ok(())
}
}