diff --git a/crates/turborepo-lib/src/cli/mod.rs b/crates/turborepo-lib/src/cli/mod.rs index 0495fe64f2457..7c3f30c210139 100644 --- a/crates/turborepo-lib/src/cli/mod.rs +++ b/crates/turborepo-lib/src/cli/mod.rs @@ -284,6 +284,25 @@ pub enum DaemonCommand { Logs, } +#[derive(Copy, Clone, Debug, Default, ValueEnum, Serialize, Eq, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum OutputFormat { + /// Output in a human-readable format + #[default] + Pretty, + /// Output in JSON format for direct parsing + Json, +} + +impl fmt::Display for OutputFormat { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + OutputFormat::Pretty => "pretty", + OutputFormat::Json => "json", + }) + } +} + #[derive(Subcommand, Copy, Clone, Debug, PartialEq)] pub enum TelemetryCommand { /// Enables anonymous telemetry @@ -507,6 +526,9 @@ pub enum Command { /// Get insight into a specific package, such as /// its dependencies and tasks packages: Vec, + /// Output format + #[clap(long, value_enum)] + output: Option, }, /// Link your local directory to a Vercel organization and enable remote /// caching. @@ -1165,16 +1187,18 @@ pub async fn run( affected, filter, packages, + output, } => { warn!("ls command is experimental and may change in the future"); let event = CommandEventBuilder::new("info").with_parent(&root_telemetry); event.track_call(); let affected = *affected; + let output = *output; let filter = filter.clone(); let packages = packages.clone(); let base = CommandBase::new(cli_args, repo_root, version, color_config); - ls::run(base, packages, event, filter, affected).await?; + ls::run(base, packages, event, filter, affected, output).await?; Ok(0) } diff --git a/crates/turborepo-lib/src/commands/ls.rs b/crates/turborepo-lib/src/commands/ls.rs index f6e09a7075f0b..1d9f284ef1c5e 100644 --- a/crates/turborepo-lib/src/commands/ls.rs +++ b/crates/turborepo-lib/src/commands/ls.rs @@ -1,6 +1,7 @@ //! A command for outputting info about packages and tasks in a turborepo. use miette::Diagnostic; +use serde::Serialize; use thiserror::Error; use turbopath::AnchoredSystemPath; use turborepo_repository::{ @@ -12,7 +13,7 @@ use turborepo_ui::{color, cprint, cprintln, ColorConfig, BOLD, BOLD_GREEN, GREY} use crate::{ cli, - cli::{Command, ExecutionArgs}, + cli::{Command, ExecutionArgs, OutputFormat}, commands::{run::get_signal, CommandBase}, run::{builder::RunBuilder, Run}, signal::SignalHandler, @@ -24,25 +25,100 @@ pub enum Error { PackageNotFound { package: String }, } +#[derive(Serialize)] +struct ItemsWithCount { + count: usize, + items: Vec, +} + +#[derive(Clone, Serialize)] +#[serde(into = "RepositoryDetailsDisplay<'a>")] struct RepositoryDetails<'a> { color_config: ColorConfig, package_manager: &'a PackageManager, packages: Vec<(&'a PackageName, &'a AnchoredSystemPath)>, } +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct RepositoryDetailsDisplay<'a> { + package_manager: &'a PackageManager, + packages: ItemsWithCount, +} + +#[derive(Serialize)] +struct PackageDetailDisplay { + name: String, + path: String, +} + +impl<'a> From> for RepositoryDetailsDisplay<'a> { + fn from(val: RepositoryDetails<'a>) -> Self { + RepositoryDetailsDisplay { + package_manager: val.package_manager, + packages: ItemsWithCount { + count: val.packages.len(), + items: val + .packages + .into_iter() + .map(|(name, path)| PackageDetailDisplay { + name: name.to_string(), + path: path.to_string(), + }) + .collect(), + }, + } + } +} + +#[derive(Clone, Serialize)] +struct PackageTask<'a> { + name: &'a str, + command: &'a str, +} + +#[derive(Clone, Serialize)] +#[serde(into = "PackageDetailsDisplay<'a>")] struct PackageDetails<'a> { + #[serde(skip)] color_config: ColorConfig, name: &'a str, - tasks: Vec<(&'a str, &'a str)>, + tasks: Vec>, + dependencies: Vec<&'a str>, +} + +#[derive(Clone, Serialize)] +struct PackageDetailsList<'a> { + packages: Vec>, +} + +#[derive(Serialize)] +struct PackageDetailsDisplay<'a> { + name: &'a str, + tasks: ItemsWithCount>, dependencies: Vec<&'a str>, } +impl<'a> From> for PackageDetailsDisplay<'a> { + fn from(val: PackageDetails<'a>) -> Self { + PackageDetailsDisplay { + name: val.name, + dependencies: val.dependencies, + tasks: ItemsWithCount { + count: val.tasks.len(), + items: val.tasks, + }, + } + } +} + pub async fn run( mut base: CommandBase, packages: Vec, telemetry: CommandEventBuilder, filter: Vec, affected: bool, + output: Option, ) -> Result<(), cli::Error> { let signal = get_signal()?; let handler = SignalHandler::new(signal); @@ -61,11 +137,26 @@ pub async fn run( let run = run_builder.build(&handler, telemetry).await?; if packages.is_empty() { - RepositoryDetails::new(&run).print()?; + RepositoryDetails::new(&run).print(output)?; } else { - for package in packages { - let package_details = PackageDetails::new(&run, &package)?; - package_details.print(); + match output { + Some(OutputFormat::Json) => { + let mut package_details_list = PackageDetailsList { packages: vec![] }; + // collect all package details + for package in &packages { + let package_details = PackageDetails::new(&run, package)?; + package_details_list.packages.push(package_details); + } + + let as_json = serde_json::to_string_pretty(&package_details_list)?; + println!("{}", as_json); + } + Some(OutputFormat::Pretty) | None => { + for package in packages { + let package_details = PackageDetails::new(&run, &package)?; + package_details.print(); + } + } } } @@ -99,21 +190,42 @@ impl<'a> RepositoryDetails<'a> { packages, } } - fn print(&self) -> Result<(), cli::Error> { - if self.packages.len() == 1 { - cprintln!(self.color_config, BOLD, "{} package\n", self.packages.len()); - } else { - cprintln!( - self.color_config, - BOLD, - "{} packages\n", - self.packages.len() - ); - } + fn pretty_print(&self) { + let package_copy = match self.packages.len() { + 0 => "no packages", + 1 => "package", + _ => "packages", + }; + + cprint!( + self.color_config, + BOLD, + "{} {} ", + self.packages.len(), + package_copy + ); + cprintln!(self.color_config, GREY, "({})\n", self.package_manager); for (package_name, entry) in &self.packages { println!(" {} {}", package_name, GREY.apply_to(entry)); } + } + + fn json_print(&self) -> Result<(), cli::Error> { + let as_json = serde_json::to_string_pretty(&self)?; + println!("{}", as_json); + Ok(()) + } + + fn print(&self, output: Option) -> Result<(), cli::Error> { + match output { + Some(OutputFormat::Json) => { + self.json_print()?; + } + Some(OutputFormat::Pretty) | None => { + self.pretty_print(); + } + } Ok(()) } @@ -153,7 +265,7 @@ impl<'a> PackageDetails<'a> { tasks: package_json .scripts .iter() - .map(|(name, command)| (name.as_str(), command.as_str())) + .map(|(name, command)| PackageTask { name, command }) .collect(), }) } @@ -180,11 +292,11 @@ impl<'a> PackageDetails<'a> { } else { println!(); } - for (name, command) in &self.tasks { + for task in &self.tasks { println!( " {}: {}", - name, - color!(self.color_config, GREY, "{}", command) + task.name, + color!(self.color_config, GREY, "{}", task.command) ); } println!(); diff --git a/turborepo-tests/integration/tests/affected.t b/turborepo-tests/integration/tests/affected.t index 02031a33f0196..d872b33c484c8 100644 --- a/turborepo-tests/integration/tests/affected.t +++ b/turborepo-tests/integration/tests/affected.t @@ -28,7 +28,7 @@ Validate that we only run `my-app#build` with change not committed Do the same thing with the `ls` command $ ${TURBO} ls --affected WARNING ls command is experimental and may change in the future - 1 package + 1 package (npm) my-app apps[\/\\]my-app (re) @@ -56,7 +56,7 @@ Validate that we only run `my-app#build` with change committed Do the same thing with the `ls` command $ ${TURBO} ls --affected WARNING ls command is experimental and may change in the future - 1 package + 1 package (npm) my-app apps[\/\\]my-app (re) @@ -76,7 +76,7 @@ Override the SCM base to be HEAD, so nothing runs Do the same thing with the `ls` command $ TURBO_SCM_BASE="HEAD" ${TURBO} ls --affected WARNING ls command is experimental and may change in the future - 0 packages + 0 no packages (npm) @@ -96,7 +96,7 @@ Override the SCM head to be main, so nothing runs Do the same thing with the `ls` command $ TURBO_SCM_HEAD="main" ${TURBO} ls --affected WARNING ls command is experimental and may change in the future - 0 packages + 0 no packages (npm) @@ -127,7 +127,7 @@ Run the build and expect only `my-app` to be affected, since between Do the same thing with the `ls` command $ ${TURBO} ls --affected WARNING ls command is experimental and may change in the future - 1 package + 1 package (npm) my-app apps[\/\\]my-app (re) @@ -167,7 +167,7 @@ Do the same thing with the `ls` command WARNING ls command is experimental and may change in the future WARNING unable to detect git range, assuming all files have changed: git error: fatal: main...HEAD: no merge base - 3 packages + 3 packages (npm) another packages[\/\\]another (re) my-app apps[\/\\]my-app (re) diff --git a/turborepo-tests/integration/tests/command-ls.t b/turborepo-tests/integration/tests/command-ls.t index d0ab97647f9e2..ebf8c9f3042f8 100644 --- a/turborepo-tests/integration/tests/command-ls.t +++ b/turborepo-tests/integration/tests/command-ls.t @@ -4,16 +4,40 @@ Setup Run info $ ${TURBO} ls WARNING ls command is experimental and may change in the future - 3 packages + 3 packages (npm) another packages[\/\\]another (re) my-app apps[\/\\]my-app (re) util packages[\/\\]util (re) +Run info with json output + $ ${TURBO} ls --output=json + WARNING ls command is experimental and may change in the future + { + "packageManager": "npm", + "packages": { + "count": 3, + "items": [ + { + "name": "another", + "path": "packages(\/|\\\\)another" (re) + }, + { + "name": "my-app", + "path": "apps(\/|\\\\)my-app" (re) + }, + { + "name": "util", + "path": "packages(\/|\\\\)util" (re) + } + ] + } + } + Run info with filter $ ${TURBO} ls -F my-app... WARNING ls command is experimental and may change in the future - 2 packages + 2 packages (npm) my-app apps[\/\\]my-app (re) util packages[\/\\]util (re) @@ -35,3 +59,29 @@ Run info on package `my-app` build: echo building maybefails: exit 4 +Run info on package `my-app` with json output + $ ${TURBO} ls my-app --output=json + WARNING ls command is experimental and may change in the future + { + "packages": [ + { + "name": "my-app", + "tasks": { + "count": 2, + "items": [ + { + "name": "build", + "command": "echo building" + }, + { + "name": "maybefails", + "command": "exit 4" + } + ] + }, + "dependencies": [ + "util" + ] + } + ] + }