3 Commits
dev ... v0.9.5

Author SHA1 Message Date
2258ae53fe fix: avoid implicit clang target on native builds 2026-10-21 21:50:42 +08:00
0f2182fe96 Merge origin/master into master 2026-10-21 20:50:46 +08:00
0f456b4201 feat: complete structured CLI output
- unify human, plain, and JSON CLI output modes

- standardize diagnostics, lifecycle events, colors, quiet, and verbose handling

- preserve argument forwarding and improve CLI help and summaries

- document and release version 0.9.5
2026-10-21 18:55:23 +08:00
30 changed files with 1186 additions and 541 deletions

View File

@@ -1,5 +1,37 @@
# Changelog
## [v0.9.5] - 2026-10-21 "Структурированный вывод CLI / Structured CLI Output"
### RU
**Добавлено:**
- Человекочитаемый, plain- и JSON-режимы вывода в стиле Cargo с общими хелперами вывода.
- Явная обработка `--color auto|always|never`, `NO_COLOR`, `--quiet` и `--verbose`.
- Структурированные события жизненного цикла сборки и JSON-диагностика компилятора.
- Сценарии CLI output sandbox для help, жизненного цикла, диагностики, ошибок и broken pipe.
**Исправлено:**
- Единообразная маршрутизация stdout/stderr и обработка цветов во всех основных командах.
- Глобальный парсер опций теперь останавливается на subcommand и сохраняет аргументы после `--`.
- Перенаправленный вывод подпроцессов теперь захватывается конкурентно в output lab.
### EN
**Added:**
- Cargo-like human, plain, and JSON output modes with shared output helpers.
- Explicit `--color auto|always|never`, `NO_COLOR`, `--quiet`, and `--verbose` handling.
- Structured build lifecycle events and JSON compiler diagnostics.
- CLI output sandbox scenarios for help, lifecycle, diagnostics, errors, and broken pipes.
**Fixed:**
- Consistent stdout/stderr routing and color handling across the main commands.
- Global option parsing now stops at the subcommand and preserves arguments after `--`.
- Redirected subprocess output is captured concurrently in the output lab.
## [v0.9.1] - 2026-09-13 "Системные pkg-config-зависимости / System pkg-config Dependencies"
### RU

48
Cargo.lock generated
View File

@@ -219,7 +219,7 @@ dependencies = [
[[package]]
name = "dcr"
version = "0.9.1"
version = "0.9.5"
dependencies = [
"ctrlc",
"env_logger",
@@ -452,6 +452,12 @@ version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -597,6 +603,23 @@ dependencies = [
"serde_core",
]
[[package]]
name = "is-terminal"
version = "0.4.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46"
dependencies = [
"hermit-abi",
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "is_ci"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45"
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
@@ -751,6 +774,10 @@ name = "owo-colors"
version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c45bb4a6ae1280ec0803b1ef9d3455eb50f01efbbe1447ab020f1d54fba9d8"
dependencies = [
"supports-color 2.1.0",
"supports-color 3.0.2",
]
[[package]]
name = "percent-encoding"
@@ -1037,6 +1064,25 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "supports-color"
version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6398cde53adc3c4557306a96ce67b302968513830a77a95b2b17305d9719a89"
dependencies = [
"is-terminal",
"is_ci",
]
[[package]]
name = "supports-color"
version = "3.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6"
dependencies = [
"is_ci",
]
[[package]]
name = "syn"
version = "2.0.117"

View File

@@ -1,6 +1,6 @@
[package]
name = "dcr"
version = "0.9.1"
version = "0.9.5"
edition = "2024"
build = "build.rs"
description = "DCR is a utility for managing C/C++ projects in a Cargo-like style."
@@ -78,7 +78,7 @@ fatfs = { version = "0.3", optional = true }
# Logging
log = "0.4.34"
env_logger = "0.11.11"
owo-colors = "4"
owo-colors = { version = "4", features = ["supports-colors"] }
[profile.release]
opt-level = "z"

View File

@@ -468,4 +468,3 @@ switch ($cmd) {
'self-install' { Cmd-SelfInstall }
default { Die "unknown command: $cmd" }
}

View File

@@ -4,6 +4,13 @@ sidebar_label: Changelog
# Changelog
## 0.9.5 (2026-10-21)
- **Structured CLI output** — shared helpers now provide Cargo-like human, plain, and JSON output modes.
- **Color and diagnostics policy** — `--color`, `NO_COLOR`, `--quiet`, and `--verbose` are applied consistently across commands.
- **Build reporting** — lifecycle events and compiler diagnostics are available as structured JSON events.
- **Output lab** — sandbox scenarios cover help, lifecycle commands, compiler warnings/errors, stream capture, and broken pipes.
## 0.9.1 (2026-09-13)
- **`pkg-config` dependencies** — declare `fmt = { pkg-config = "fmt" }` or multiple package names in `[dependencies]`; DCR applies `.pc` compiler/linker flags and records the resolved package in `dcr.lock`.

View File

@@ -0,0 +1,34 @@
---
sidebar_label: CLI output
---
# CLI output (experimental)
The structured output implementation is being developed incrementally.
Currently these global options apply to diagnostics emitted through the logger:
```sh
dcr --output plain build
dcr --output json build
dcr --color never build
dcr --quiet build
dcr --verbose build
```
`--output` accepts `human` (default), `plain`, or `json`.
Diagnostics are written to stderr. JSON diagnostics are newline-delimited objects
with `schema_version: 1`, `event`, and `message` fields. Command statuses use the
shared output helpers, build reporting emits structured lifecycle events, and
compiler output is represented as JSON events with its original stream and text.
The protocol is still experimental: some raw command data and subprocess stdout
remain unstructured.
`--color` accepts `auto`, `always`, or `never`. Automatic coloring requires a
terminal and respects `NO_COLOR`. Plain and JSON diagnostics never add colors.
Quiet mode preserves warnings, errors, and compiler diagnostics; verbose mode
enables debug and trace logging. Arguments following `--` are passed through
unchanged.
Remaining work includes migrating the remaining raw command data and subprocess
stdout, improving progress rendering and contextual errors, and expanding
integration coverage for stdout/stderr and closed pipes.

View File

@@ -1,4 +1,4 @@
.TH DCR 1 "2026-08-31" "dcr 0.9.1" "DCR Manual"
.TH DCR 1 "2026-10-21" "dcr 0.9.5" "DCR Manual"
.SH NAME
dcr \- Cargo\-like build tool and package manager for C/C++ projects
.SH SYNOPSIS

View File

@@ -18,9 +18,7 @@
use crate::prelude::*;
use crate::core::{build_config::Config, deps::register};
use crate::utils::cli_styles::{Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST};
use crate::utils::fs::find_project_root;
use crate::utils::log::sprintln;
use toml::{Value, map::Map};
/// Parsed arguments for the `dcr add` command.
@@ -59,27 +57,22 @@ pub struct AddArgs {
/// Process exit code: `0` on success, non-zero on usage or I/O errors.
pub fn add(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(
HELP_EXAMPLES_ST,
" dcr add <name> <source> [--branch <b>] [--tag <t>] [--rev <r>]"
);
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Adds a dependency to the project.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "SOURCES:");
println!(" github:user/repo GitHub repository");
println!(" gitlab:user/repo GitLab repository");
println!(" git:host.com/user/repo Generic git repository");
println!(" path:./path/to/lib Local path dependency");
println!(" pkg-config:fmt System pkg-config dependency");
println!(" <version> Version from registry");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --branch <b> Use a specific branch");
println!(" --tag <t> Use a specific tag");
println!(" --rev <r> Use a specific commit hash");
let mut help = crate::utils::help::HelpBuilder::new("dcr add");
help.section("USAGE")
.example("dcr add <name> <source> [--branch <b>] [--tag <t>] [--rev <r>]");
help.description("Adds a dependency to the project.");
help.section("SOURCES")
.item("github:user/repo", "GitHub repository")
.item("gitlab:user/repo", "GitLab repository")
.item("git:host.com/user/repo", "Generic git repository")
.item("path:./path/to/lib", "Local path dependency")
.item("pkg-config:fmt", "System pkg-config dependency")
.item("<version>", "Version from registry");
help.section("OPTIONS")
.item("--branch <b>", "Use a specific branch")
.item("--tag <t>", "Use a specific tag")
.item("--rev <r>", "Use a specific commit hash");
help.render();
return 0;
}
@@ -164,11 +157,7 @@ pub fn add(args: &[String]) -> i32 {
return 1;
}
println!(
" {} dependency `{}` to dcr.toml",
"Added".style(SUCCESS_ST),
add_args.name
);
crate::utils::output::success(format!("Added dependency `{}` to dcr.toml", add_args.name));
0
}

View File

@@ -28,37 +28,17 @@ use crate::utils::build::{
get_config_opt, get_config_str, get_language_with_profile_or_default, get_string_with_profile,
normalize_kind, resolve_artifact_target_dir, resolve_compiler,
};
use crate::utils::cli_styles::{
BOLD_CYAN, BOLD_YELLOW, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST, Style,
};
use crate::utils::fs::{canonicalize_path, find_project_root};
use crate::utils::log::sprintln;
use crate::utils::output;
use std::io::IsTerminal;
use std::path::Path;
use std::sync::{Arc, atomic::AtomicBool};
pub use crate::core::build::get_build_string_with_profile;
/// Formats a left-aligned status verb with the given style for CLI output.
fn status(verb: &str, style: Style) -> String {
format!("{verb:<9}").style(style).to_string()
}
/// CLI build reporter that prints human-readable progress to stderr/stdout.
struct CliReporter;
impl CliReporter {
/// Prints a single status line with a styled verb and free-form rest text.
///
/// # Parameters
/// - `verb`: Short status label (e.g. `"target"`, `"dep"`).
/// - `style`: Color/style constant for the verb.
/// - `rest`: Remaining message text after the verb.
fn line(&self, verb: &str, style: Style, rest: &str) {
eprintln!(" {} {}", status(verb, style), rest);
}
}
impl BuildReporter for CliReporter {
/// Handles build events by printing progress, status lines, and compiler output.
///
@@ -72,7 +52,13 @@ impl BuildReporter for CliReporter {
target,
} => {
common::finish_progress_line();
self.line("target", BOLD_CYAN, &format!("{index}/{total} {target}"));
output::emit_event(
"target_started",
serde_json::json!({
"index": index, "total": total, "target": target
}),
&format!("target {index}/{total} {target}"),
);
}
BuildEvent::ProjectStart {
name,
@@ -80,15 +66,23 @@ impl BuildReporter for CliReporter {
target,
} => {
common::finish_progress_line();
self.line(
"project",
BOLD_CYAN,
&format!("{name} ({profile}, {target})"),
output::emit_event(
"project_started",
serde_json::json!({
"name": name, "profile": profile, "target": target
}),
&format!("project {name} ({profile}, {target})"),
);
}
BuildEvent::DepBuilding { name, version } => {
common::finish_progress_line();
self.line("dep", BOLD_YELLOW, &format!("{name} v{version}"));
output::emit_event(
"dependency_building",
serde_json::json!({
"name": name, "version": version
}),
&format!("dep {name} v{version}"),
);
}
BuildEvent::DepReady {
name,
@@ -98,34 +92,55 @@ impl BuildReporter for CliReporter {
common::finish_progress_line();
// Only announce deps that were actually rebuilt this run.
if rebuilt {
self.line("ready", SUCCESS_ST, &format!("{name} v{version}"));
output::emit_event(
"dependency_ready",
serde_json::json!({
"name": name, "version": version, "rebuilt": rebuilt
}),
&format!("ready {name} v{version}"),
);
}
}
BuildEvent::Compiling { name, version } => {
common::finish_progress_line();
let label = format!(" {} {} v{}", status("compile", SUCCESS_ST), name, version);
let label = format!(
" {}",
output::progress_label("compile", &format!("{name} v{version}"))
);
// Terminals use an in-place progress line; non-TTY gets a plain log line.
common::set_progress_label(Some(label.clone()));
if !std::io::stderr().is_terminal() {
eprintln!("{label}");
if output::mode() == output::Mode::Human {
common::set_progress_label(Some(label.clone()));
}
if output::mode() != output::Mode::Human || !std::io::stderr().is_terminal() {
output::emit_event(
"compiling",
serde_json::json!({
"name": name, "version": version
}),
&format!("compile {name} v{version}"),
);
}
}
BuildEvent::Packing { path } => {
common::finish_progress_line();
self.line("pack", BOLD_CYAN, path);
output::emit_event(
"packing",
serde_json::json!({ "path": path }),
&format!("pack {path}"),
);
}
BuildEvent::Finished { secs } => {
common::finish_progress_line();
self.line("done", SUCCESS_ST, &format!("in {secs}s"));
output::emit_event(
"finished",
serde_json::json!({ "seconds": secs }),
&format!("done in {secs}s"),
);
}
BuildEvent::CompilerOutput { stream, text } => {
// Pause the progress spinner so compiler text is not overwritten.
common::interrupt_progress_for_output();
if stream == "stderr" {
eprint!("{text}");
} else {
print!("{text}");
}
output::compiler_output(stream, text);
}
}
}
@@ -144,26 +159,22 @@ impl BuildReporter for CliReporter {
/// Process exit code: `0` on success or help, non-zero on failure.
pub fn build(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(
HELP_EXAMPLES_ST,
" dcr build [--debug | --release] [--target <triple>] [--force] [--clean] [--verbose] [--print-artifact-path]"
);
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Compiles the project. Default profile is --debug.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --debug Build with debug profile (default)");
println!(" --release Build with release profile");
println!(" --target <triple> Cross-compile for the given target");
println!(" --force Force a full rebuild");
println!(" --clean Clean before building");
println!(" --verbose Print detailed build output");
println!(" --workspace <name> Build a specific workspace member");
println!(
" --print-artifact-path Print absolute main artifact path on stdout after success"
);
let mut help = crate::utils::help::HelpBuilder::new("dcr build");
help.description("Compiles the project. Default profile is --debug.");
help.section("USAGE").example("dcr build [options]");
help.section("OPTIONS")
.item("--debug", "Build with debug profile (default)")
.item("--release", "Build with release profile")
.item("--target <triple>", "Cross-compile for the given target")
.item("--force", "Force a full rebuild")
.item("--clean", "Clean before building")
.item("--verbose", "Print detailed build output")
.item("--workspace <name>", "Build a specific workspace member")
.item(
"--print-artifact-path",
"Print the absolute main artifact path",
);
help.render();
return 0;
}
@@ -234,13 +245,21 @@ pub fn build(args: &[String]) -> i32 {
if flags.print_artifact_path
&& let Some(ref path) = info.artifact_path
{
println!("{path}");
if crate::utils::output::mode() == crate::utils::output::Mode::Json {
crate::utils::output::write_stdout_raw(&format!("{path}\n"));
} else {
crate::say!("{path}");
}
}
} else if flags.print_artifact_path
&& let Some(path) =
fallback_artifact_path(&root, &flags.profile, flags.target.as_deref())
{
println!("{path}");
if crate::utils::output::mode() == crate::utils::output::Mode::Json {
crate::utils::output::write_stdout_raw(&format!("{path}\n"));
} else {
crate::say!("{path}");
}
}
0
}

View File

@@ -20,11 +20,7 @@ use crate::prelude::*;
use crate::core::build_config::Config;
use crate::core::workspace::parse_workspace;
use crate::utils::build::{default_profile_flags, default_target_triple, parse_version_info};
use crate::utils::cli_styles::{
BOLD_GREEN, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST,
};
use crate::utils::fs::{check_dir, find_project_root, with_dir};
use crate::utils::log::sprintln;
use glob::glob;
use std::{fs, path::Path};
@@ -39,20 +35,16 @@ use std::{fs, path::Path};
/// Process exit code: `0` on success, `1` on failure.
pub fn clean(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(
HELP_EXAMPLES_ST,
" dcr clean [--debug | --release] [--target <triple>] [--all]"
);
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Removes build artifacts from the target directory.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --debug Clean debug artifacts (default)");
println!(" --release Clean release artifacts");
println!(" --target <triple> Clean artifacts for a specific target");
println!(" --all Clean all workspace members");
let mut help = crate::utils::help::HelpBuilder::new("dcr clean");
help.section("USAGE")
.example("dcr clean [--debug | --release] [--target <triple>] [--all]");
help.description("Removes build artifacts from the target directory.");
help.section("OPTIONS")
.item("--debug", "Clean debug artifacts (default)")
.item("--release", "Clean release artifacts")
.item("--target <triple>", "Clean artifacts for a specific target")
.item("--all", "Clean all workspace members");
help.render();
return 0;
}
@@ -213,7 +205,7 @@ fn clean_project_at(
if !items.contains(&"dcr.toml".to_string()) {
return Err("dcr.toml file not found".to_string());
}
println!(" Cleaning project `{}`", project_name.style(BOLD_GREEN));
crate::utils::output::status("Cleaning", format!("project `{project_name}`"));
if let Some(profile) = profile {
let target_dir = if let Some(t) = target {
format!("target/{t}/{profile}")
@@ -231,16 +223,12 @@ fn clean_project_at(
if !dir_exists {
warn!("Directory target/{target_dir} not found");
} else {
println!(" Profile: {}", profile.style(BOLD_GREEN));
crate::utils::output::status("Profile", profile);
if let Some(t) = target {
println!(" Target: {}", t.style(BOLD_GREEN));
crate::utils::output::status("Target", t);
}
let _ = fs::remove_dir_all(&target_dir);
println!(
"{} Removed directory {}",
"\n ✔".style(SUCCESS_ST),
target_dir
);
crate::utils::output::removed(format!("directory {target_dir}"));
}
clean_custom_paths(&config, profile)?;
return Ok(());
@@ -248,7 +236,7 @@ fn clean_project_at(
if items.contains(&"target".to_string()) {
let _ = fs::remove_dir_all("target");
println!("{} Removed directory target", "\n ✔".style(SUCCESS_ST));
crate::utils::output::removed("directory target");
} else {
warn!("Directory target not found");
}

View File

@@ -17,8 +17,6 @@
use crate::prelude::*;
use crate::utils::cli_styles::{HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST};
use crate::utils::log::sprintln;
use serde::Deserialize;
use std::fs;
use std::io::Read;
@@ -57,11 +55,10 @@ struct ReleaseAsset {
/// `0` on success / already up to date / pacman-managed refuse; `1` on error.
pub fn flag_update(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr --update");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Updates DCR to the latest version from GitHub Releases.");
let mut help = crate::utils::help::HelpBuilder::new("dcr --update");
help.section("USAGE").example("dcr --update");
help.description("Updates DCR to the latest version from GitHub Releases.");
help.render();
return 0;
}
@@ -81,8 +78,9 @@ pub fn flag_update(args: &[String]) -> i32 {
// Refuse self-update when the binary is owned by pacman/AUR.
if let Some(package_name) = pacman_owned_package(&current_exe) {
warn!("This dcr binary is managed by pacman/AUR");
println!(
"Update via package manager: yay/paru -Syu {package_name} or sudo pacman -Syu {package_name}"
crate::utils::output::status(
"Update via package manager:",
format!("yay/paru -Syu {package_name} or sudo pacman -Syu {package_name}"),
);
return 0;
}
@@ -101,7 +99,7 @@ pub fn flag_update(args: &[String]) -> i32 {
// Normalize the tag by stripping a leading `v` before comparison.
let latest_version = release.tag_name.trim_start_matches('v');
if latest_version == current_version {
println!("Latest version is already installed: {current_version}");
crate::utils::output::status("Up to date", current_version);
return 0;
}
@@ -139,7 +137,9 @@ pub fn flag_update(args: &[String]) -> i32 {
}
let _ = fs::remove_file(&temp_path);
println!("Update completed: {current_version} -> {latest_version}");
crate::utils::output::success(format!(
"Updated DCR: {current_version} -> {latest_version}"
));
0
}

View File

@@ -17,10 +17,6 @@
use crate::prelude::*;
use crate::utils::cli_styles::{
BOLD_GREEN, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST,
};
use crate::utils::log::sprintln;
use glob::glob;
use std::process::Command;
@@ -32,12 +28,11 @@ use std::process::Command;
/// If the first argument is `--help`, prints usage information and returns 0.
pub fn fmt(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr fmt");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Formats all C/C++ source files using clang-format.");
println!(" Scans src/ and tests/ directories recursively.");
let mut help = crate::utils::help::HelpBuilder::new("dcr fmt");
help.section("USAGE").example("dcr fmt");
help.description("Formats all C/C++ source files using clang-format.")
.note("Scans src/ and tests/ directories recursively.");
help.render();
return 0;
}
@@ -73,22 +68,18 @@ pub fn fmt(args: &[String]) -> i32 {
}
if files.is_empty() {
println!(" {} No files to format", "Format".style(BOLD_GREEN));
crate::utils::output::status("Format", "no files to format");
return 0;
}
// Format the collected files
println!(
" {} {} files",
"Formatting".style(BOLD_GREEN),
files.len()
);
crate::utils::output::status("Formatting", format!("{} files", files.len()));
let status = Command::new("clang-format").arg("-i").args(&files).status();
match status {
Ok(s) if s.success() => {
println!(" {} successful", "Format".style(SUCCESS_ST));
crate::utils::output::success("Format successful");
0
}
Ok(s) => {

View File

@@ -28,11 +28,9 @@ use crate::utils::build::{
get_list_with_profile, get_string_with_profile, normalize_kind, normalize_platform,
resolve_artifact_target_dir, resolve_compiler, resolve_pkg_config_flags_lossy,
};
use crate::utils::cli_styles::{HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST};
use crate::utils::fs::{
absolute_join, atomic_write, canonicalize_path, ensure_dcr_dir, find_project_root,
};
use crate::utils::log::sprintln;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
@@ -253,33 +251,30 @@ pub struct ProjectInfo {
pub fn r#gen(args: &[String]) -> i32 {
let subcommand = match args.first() {
Some(s) if s == "--help" => {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr gen <subcommand>");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Generates IDE and tooling integration files.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "SUBCOMMANDS:");
println!(" project-info Print project metadata as JSON");
println!(" compile-commands Generate .dcr/compile_commands.json");
println!(" vscode Generate .vscode/ integration files");
println!(" clion Generate .idea/ integration files");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --debug | --release Profile (default: debug)");
println!(" --quiet | -q Suppress success messages on stdout");
let mut help = crate::utils::help::HelpBuilder::new("dcr gen");
help.section("USAGE").example("dcr gen <subcommand>");
help.description("Generates IDE and tooling integration files.");
help.section("SUBCOMMANDS")
.item("project-info", "Print project metadata as JSON")
.item("compile-commands", "Generate .dcr/compile_commands.json")
.item("vscode", "Generate .vscode/ integration files")
.item("clion", "Generate .idea/ integration files");
help.section("OPTIONS")
.item("--debug | --release", "Profile (default: debug)")
.item("--quiet | -q", "Suppress success messages on stdout");
help.render();
return 0;
}
Some(s) => s.as_str(),
None => {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr gen <subcommand>");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "SUBCOMMANDS:");
println!(" project-info Print project metadata as JSON");
println!(" compile-commands Generate .dcr/compile_commands.json");
println!(" vscode Generate .vscode/ integration files");
println!(" clion Generate .idea/ integration files");
let mut help = crate::utils::help::HelpBuilder::new("dcr gen");
help.section("USAGE").example("dcr gen <subcommand>");
help.section("SUBCOMMANDS")
.item("project-info", "Print project metadata as JSON")
.item("compile-commands", "Generate .dcr/compile_commands.json")
.item("vscode", "Generate .vscode/ integration files")
.item("clion", "Generate .idea/ integration files");
help.render();
return 1;
}
};
@@ -778,8 +773,8 @@ fn ensure_clangd(root: &Path, quiet: bool) {
}
Ok(_) => {
if !quiet {
eprintln!(
"Warning: .clangd already exists and is not managed by dcr; \
crate::utils::output::warning!(
".clangd already exists and is not managed by dcr; \
set CompilationDatabase to .dcr manually if needed"
);
}
@@ -818,8 +813,8 @@ fn collect_all(root: &Path, profile: &str) -> Result<Vec<ProjectInfo>, String> {
let member_path = canonicalize_path(&member.path);
match collect_project_info(&member_path, profile, ws_root) {
Ok(info) => all.push(info),
Err(e) => eprintln!(
"Warning: skipping workspace member {}: {e}",
Err(e) => crate::utils::output::warning!(
"skipping workspace member {}: {e}",
member_path.display()
),
}
@@ -830,7 +825,7 @@ fn collect_all(root: &Path, profile: &str) -> Result<Vec<ProjectInfo>, String> {
match collect_project_info(&root, profile, ws_root) {
Ok(info) => all.push(info),
Err(e) if is_workspace => {
eprintln!("Warning: skipping workspace root package: {e}");
crate::utils::output::warning!("skipping workspace root package: {e}");
}
Err(e) => return Err(e),
}
@@ -864,20 +859,12 @@ fn gen_project_info(args: &[String]) -> i32 {
&& let Err(e) = write_dcr_metadata(&root, info)
&& !quiet
{
eprintln!("Warning: {e}");
crate::utils::output::warning!("{e}");
}
ensure_clangd(&root, quiet);
print!("[");
for (i, info) in all.iter().enumerate() {
if i > 0 {
print!(",");
}
println!();
print!("{}", project_info_to_json(info));
}
println!();
println!("]");
let entries = all.iter().map(project_info_to_json).collect::<Vec<_>>();
crate::utils::output::write_stdout_raw(&format!("[\n{}\n]\n", entries.join(",\n")));
0
}
@@ -1002,11 +989,11 @@ fn gen_compile_commands_inner(root: &Path, profile: &str, all: &[ProjectInfo], q
&& let Err(e) = write_dcr_metadata(root, info)
&& !quiet
{
eprintln!("Warning: {e}");
crate::utils::output::warning!("{e}");
}
ensure_clangd(root, quiet);
if !quiet {
println!("Generated {}", out_path.display());
crate::utils::output::created(format!("{}", out_path.display()));
}
0
}
@@ -1187,7 +1174,7 @@ fn gen_vscode(args: &[String]) -> i32 {
error!("Failed to write tasks.json: {e}");
return 1;
}
println!("Generated {}", vscode_dir.join("tasks.json").display());
crate::utils::output::created(format!("{}", vscode_dir.join("tasks.json").display()));
// launch.json — one entry per binary target
let launch = gen_launch_json(&all, &root);
@@ -1195,7 +1182,7 @@ fn gen_vscode(args: &[String]) -> i32 {
error!("Failed to write launch.json: {e}");
return 1;
}
println!("Generated {}", vscode_dir.join("launch.json").display());
crate::utils::output::created(format!("{}", vscode_dir.join("launch.json").display()));
// settings.json (clangd compile-commands-dir)
let settings = gen_settings_json(&root);
@@ -1203,14 +1190,14 @@ fn gen_vscode(args: &[String]) -> i32 {
error!("Failed to write settings.json: {e}");
return 1;
}
println!("Generated {}", vscode_dir.join("settings.json").display());
crate::utils::output::created(format!("{}", vscode_dir.join("settings.json").display()));
// extensions.json — disable cpptools, recommend clangd
if let Err(e) = std::fs::write(vscode_dir.join("extensions.json"), gen_extensions_json()) {
error!("Failed to write extensions.json: {e}");
return 1;
}
println!("Generated {}", vscode_dir.join("extensions.json").display());
crate::utils::output::created(format!("{}", vscode_dir.join("extensions.json").display()));
0
}
@@ -1483,7 +1470,7 @@ fn gen_clion(args: &[String]) -> i32 {
error!("Failed to write externalTools.xml: {e}");
return 1;
}
println!("Generated {}", idea_dir.join("externalTools.xml").display());
crate::utils::output::created(format!("{}", idea_dir.join("externalTools.xml").display()));
// customTargets.xml
let targets = gen_clion_custom_targets();
@@ -1491,7 +1478,7 @@ fn gen_clion(args: &[String]) -> i32 {
error!("Failed to write customTargets.xml: {e}");
return 1;
}
println!("Generated {}", idea_dir.join("customTargets.xml").display());
crate::utils::output::created(format!("{}", idea_dir.join("customTargets.xml").display()));
// misc.xml — point CLion at compile_commands.json
let misc = gen_clion_misc_xml(&root);
@@ -1499,7 +1486,7 @@ fn gen_clion(args: &[String]) -> i32 {
error!("Failed to write misc.xml: {e}");
return 1;
}
println!("Generated {}", idea_dir.join("misc.xml").display());
crate::utils::output::created(format!("{}", idea_dir.join("misc.xml").display()));
// .idea/.gitignore
let gitignore = "# CLion generated files\nworkspace.xml\n*.iml\n";
@@ -1507,7 +1494,7 @@ fn gen_clion(args: &[String]) -> i32 {
error!("Failed to write .idea/.gitignore: {e}");
return 1;
}
println!("Generated {}", idea_dir.join(".gitignore").display());
crate::utils::output::created(format!("{}", idea_dir.join(".gitignore").display()));
// runConfigurations/<name>.xml — one per binary
for info in &all {
@@ -1521,7 +1508,7 @@ fn gen_clion(args: &[String]) -> i32 {
error!("Failed to write runConfigurations/{fname}: {e}");
return 1;
}
println!("Generated {}", path.display());
crate::utils::output::created(format!("{}", path.display()));
}
0

View File

@@ -15,9 +15,6 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::utils::cli_styles::{HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST};
use crate::utils::log::sprintln;
/// Displays the help information for the DCR CLI tool.
///
/// Prints usage, commands, flags, options, examples, and a tip.
@@ -25,43 +22,40 @@ use crate::utils::log::sprintln;
/// # Returns
/// Always `0`.
pub fn help() -> i32 {
println!("DCR (Dexoron Cargo Realization)");
println!("C project manager inspired by Cargo.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr <command> [options]");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "COMMANDS:");
println!(" new <name> Create a new project");
println!(" init Initialize the current directory as a project");
println!(" build [--profile] Build the project (default: --debug)");
println!(" run [--profile] Build and run the project (default: --debug)");
println!(" add <name> ... Add a dependency to dcr.toml");
println!(" tree Display the dependency tree");
println!(" test Run project tests (alias: tests)");
println!(" fmt Format all C/C++ source files using clang-format");
println!(" lint Run clang-tidy on C/C++ sources");
println!(" setup Show configured package registries");
println!(" clean Remove the target directory");
println!(" gen <subcommand> Generate IDE integration files");
sprintln!(HELP_SECTION_TITLE_ST, "FLAGS:");
println!(" --help Show command help");
println!(" --update Update dcr to the latest version");
println!(" --version Show dcr version");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --debug Use debug profile");
println!(" --release Use release profile");
println!(" --force Force rebuild (build/run)");
println!(" --clean Clean before build (build/run)");
println!(" --all Clean all workspace members (clean)");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "EXAMPLES:");
sprintln!(HELP_EXAMPLES_ST, " dcr new hello");
sprintln!(HELP_EXAMPLES_ST, " dcr build --release");
sprintln!(HELP_EXAMPLES_ST, " dcr run --debug");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "TIP:");
println!(" Run 'dcr <command> --help' for command-specific help.");
use crate::utils::help::HelpBuilder;
let mut help = HelpBuilder::new("DCR (Dexoron Cargo Realization)");
help.description("C/C++ project manager inspired by Cargo.");
help.section("USAGE").example("dcr <command> [options]");
help.section("COMMANDS")
.item("new <name>", "Create a new project")
.item("init", "Initialize the current directory as a project")
.item("build", "Build the project (default: --debug)")
.item("run", "Build and run the project")
.item("add <name> ...", "Add a dependency to dcr.toml")
.item("tree", "Display the dependency tree")
.item("test", "Run project tests (alias: tests)")
.item("fmt", "Format C/C++ sources using clang-format")
.item("lint", "Run clang-tidy on C/C++ sources")
.item("setup", "Show configured package registries")
.item("clean", "Remove build artifacts")
.item("gen <subcommand>", "Generate IDE integration files");
help.section("FLAGS")
.item("--help", "Show command help")
.item("--update", "Update DCR")
.item("--version", "Show DCR version");
help.section("OPTIONS")
.item("--output human|plain|json", "Select output format")
.item("--color auto|always|never", "Select color policy")
.item("--quiet", "Suppress ordinary diagnostics")
.item("--verbose", "Enable detailed diagnostics")
.item("--debug / --release", "Select build profile")
.item("--force", "Force rebuild (build/run)")
.item("--clean", "Clean before build (build/run)")
.item("--all", "Clean all workspace members");
help.example("dcr new hello")
.example("dcr build --release")
.example("dcr run --debug");
help.tip("Run 'dcr <command> --help' for command-specific help.");
help.render();
0
}

View File

@@ -20,11 +20,7 @@ use crate::prelude::*;
use crate::config::FILE_MAIN_C;
use crate::core::build_config::{Config, validate_package_name};
use crate::core::vcs::VcsKind;
use crate::utils::cli_styles::{
BOLD_CYAN, BOLD_GREEN, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST,
};
use crate::utils::fs::check_dir;
use crate::utils::log::sprintln;
use std::{fs, io::Write};
/// Initializes the current working directory as a new DCR project.
@@ -33,12 +29,11 @@ use std::{fs, io::Write};
/// `dcr.toml` and `src/main.c`, and configures version control (Git) integration if applicable.
pub fn init(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr init [--vcs <git|none>]");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Initializes the current directory as a DCR project.");
println!(" The directory must be empty.");
let mut help = crate::utils::help::HelpBuilder::new("dcr init");
help.section("USAGE").example("dcr init [--vcs <git|none>]");
help.description("Initializes the current directory as a DCR project.")
.note("The directory must be empty.");
help.render();
return 0;
}
@@ -79,28 +74,21 @@ pub fn init(args: &[String]) -> i32 {
}
if let Err(e) = validate_package_name(&project_name) {
error!(
"Invalid project name `{}`: {e}",
project_name.style(BOLD_CYAN),
);
error!("Invalid project name `{}`: {e}", project_name,);
return 1;
}
let cwd = std::env::current_dir()
.map(|v| v.to_string_lossy().to_string())
.unwrap_or_else(|_| ".".to_string());
println!("Initializing the project in {cwd}");
crate::utils::output::status("Initializing", format!("project in {cwd}"));
// Create and initialize default manifest (dcr.toml).
if Config::create("./dcr.toml", Some(&project_name)).is_err() {
error!("Failed to create dcr.toml");
return 1;
}
println!(
" {} Created file {}",
"✔".style(SUCCESS_ST),
"dcr.toml".style(BOLD_CYAN)
);
crate::utils::output::created("dcr.toml");
// Create src directory and write template main.c file.
if fs::create_dir("src").is_err() {
@@ -118,11 +106,7 @@ pub fn init(args: &[String]) -> i32 {
error!("Failed to write src/main.c");
return 1;
}
println!(
" {} Created file {}",
"✔".style(SUCCESS_ST),
"src/main.c".style(BOLD_CYAN)
);
crate::utils::output::created("src/main.c");
// Resolve target VCS provider or detect existing repositories.
let mut vcs_kind = VcsKind::Git;
@@ -146,7 +130,7 @@ pub fn init(args: &[String]) -> i32 {
if let Err(e) = crate::core::vcs::init_vcs(vcs_kind, project_path) {
warn!("Failed to initialize git repository: {e}");
} else {
println!(" {} Initialized git repository", "✔".style(SUCCESS_ST));
crate::utils::output::success("Initialized git repository");
}
} else {
warn!(
@@ -159,11 +143,7 @@ pub fn init(args: &[String]) -> i32 {
warn!("Failed to update .gitignore: {e}");
}
println!(
"Project `{}` successfully created\n",
project_name.style(SUCCESS_ST)
);
sprintln!(BOLD_GREEN, "Next step:");
sprintln!(BOLD_CYAN, " dcr run");
crate::utils::output::success(format!("Initialized project `{project_name}`"));
crate::utils::output::info("Next step:\n dcr run");
0
}

View File

@@ -20,11 +20,7 @@ use crate::prelude::*;
use crate::core::build::common;
use crate::core::build_config::Config;
use crate::utils::build::{get_bool_with_profile, get_list_with_profile};
use crate::utils::cli_styles::{
ALERT_ST, BOLD_GREEN, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST,
};
use crate::utils::fs::find_project_root;
use crate::utils::log::sprintln;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -33,15 +29,13 @@ use std::process::Command;
/// Scans source roots (configurable via build.roots) and tests/ directory.
pub fn lint(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr lint [--fix]");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Runs clang-tidy on all C/C++ source files.");
println!(" Scans source roots (configurable via build.roots) and tests/ directory.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --fix Apply clang-tidy suggestions automatically");
let mut help = crate::utils::help::HelpBuilder::new("dcr lint");
help.section("USAGE").example("dcr lint [--fix]");
help.description("Runs clang-tidy on all C/C++ source files.")
.note("Scans source roots (configurable via build.roots) and tests/ directory.");
help.section("OPTIONS")
.item("--fix", "Apply clang-tidy suggestions automatically");
help.render();
return 0;
}
@@ -120,15 +114,17 @@ pub fn lint(args: &[String]) -> i32 {
files.sort();
if files.is_empty() {
println!(" {} No files to lint", "Lint".style(BOLD_GREEN));
crate::utils::output::status("Lint", "no files to lint");
return 0;
}
println!(
" {} {} files {}",
"Linting".style(BOLD_GREEN),
files.len(),
if do_fix { "(with --fix)" } else { "" }
crate::utils::output::status(
"Linting",
format!(
"{} files {}",
files.len(),
if do_fix { "(with --fix)" } else { "" }
),
);
let mut total_issues = 0u32;
@@ -164,14 +160,12 @@ pub fn lint(args: &[String]) -> i32 {
if issue_count > 0 {
total_issues += issue_count as u32;
failed += 1;
print!(" {} ", "×".style(ALERT_ST));
println!("{} ({} issues)", file, issue_count);
crate::utils::output::warning(format!("{file} ({issue_count} issues)"));
for issue in &issues {
println!(" {}", issue);
crate::utils::output::compiler_output("stderr", issue);
}
} else {
print!(" {} ", "✓".style(SUCCESS_ST));
println!("{}", file);
crate::utils::output::status("Checked", file);
}
}
@@ -179,19 +173,11 @@ pub fn lint(args: &[String]) -> i32 {
let passed = total - failed as usize;
if failed == 0 {
println!(
" {} {} files, no issues",
"Lint".style(SUCCESS_ST),
total
);
crate::utils::output::success(format!("Finished checking {total} files; no issues"));
} else {
println!(
" {} {}/{} files passed, {} issues found",
"Lint".style(ALERT_ST),
passed,
total,
total_issues
);
crate::utils::output::warning(format!(
"Finished checking {total} files; {passed} passed, {total_issues} issues found"
));
}
0

View File

@@ -20,11 +20,7 @@ use crate::prelude::*;
use crate::config::FILE_MAIN_C;
use crate::core::build_config::{Config, validate_package_name};
use crate::core::vcs::VcsKind;
use crate::utils::cli_styles::{
BOLD_CYAN, BOLD_GREEN, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SUCCESS_ST,
};
use crate::utils::fs::check_dir;
use crate::utils::log::sprintln;
use std::{fs, io::Write};
/// Executes the `dcr new` command to scaffold a new C/C++ project directory structure.
@@ -33,12 +29,12 @@ use std::{fs, io::Write};
/// template source file generation (`src/main.c`), and initial VCS setup.
pub fn new(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr new <name> [--vcs <git|none>]");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Creates a new C/C++ project with the given name.");
println!(" The name may only contain ASCII letters, digits, '_' and '-'.");
let mut help = crate::utils::help::HelpBuilder::new("dcr new");
help.section("USAGE")
.example("dcr new <name> [--vcs <git|none>]");
help.description("Creates a new C/C++ project with the given name.")
.note("The name may only contain ASCII letters, digits, '_' and '-'.");
help.render();
return 0;
}
@@ -73,38 +69,27 @@ pub fn new(args: &[String]) -> i32 {
}
let project_name = &clean_args[0];
println!("Creating a Project `{}`...", project_name.style(BOLD_CYAN));
if let Err(e) = validate_package_name(project_name) {
error!(
"Invalid project name `{}`: {e}",
project_name.style(BOLD_CYAN),
);
error!("Invalid project name `{}`: {e}", project_name,);
return 1;
}
if items.contains(project_name) {
error!(
"Directory `{}` already exists\n",
project_name.style(BOLD_CYAN)
);
sprintln!(BOLD_CYAN, "Hint:");
println!(
error!("Directory `{}` already exists", project_name);
crate::utils::output::info(format!(
" Use `{}` to initialize an existing project\n or specify a different project name",
"dcr init".style(BOLD_CYAN)
);
"dcr init"
));
return 1;
}
crate::utils::output::status("Creating", format!("project `{project_name}`"));
if fs::create_dir(project_name).is_err() {
error!("Failed to create directory");
return 1;
}
println!(
" {} Directory created {}",
"✔".style(SUCCESS_ST),
project_name
);
crate::utils::output::created(format!("directory `{project_name}`"));
// Initialize and write project configuration manifest (dcr.toml).
let toml_path = format!("./{project_name}/dcr.toml");
@@ -112,11 +97,7 @@ pub fn new(args: &[String]) -> i32 {
error!("Failed to create dcr.toml");
return 1;
}
println!(
" {} Created file {}",
"✔".style(SUCCESS_ST),
"dcr.toml".style(BOLD_CYAN)
);
crate::utils::output::created("dcr.toml");
// Generate src directory and populate initial main.c file.
if fs::create_dir_all(format!("./{project_name}/src")).is_err() {
@@ -135,11 +116,7 @@ pub fn new(args: &[String]) -> i32 {
error!("Failed to write src/main.c");
return 1;
}
println!(
" {} Created file {}",
"✔".style(SUCCESS_ST),
"src/main.c".style(BOLD_CYAN)
);
crate::utils::output::created("src/main.c");
// Resolve target VCS mode based on CLI flags or parent directory state.
let mut vcs_kind = VcsKind::Git;
@@ -164,7 +141,7 @@ pub fn new(args: &[String]) -> i32 {
if let Err(e) = crate::core::vcs::init_vcs(vcs_kind, project_path) {
warn!("Failed to initialize git repository: {e}");
} else {
println!(" {} Initialized git repository", "✔".style(SUCCESS_ST));
crate::utils::output::success("Initialized git repository");
}
} else {
warn!(
@@ -178,11 +155,7 @@ pub fn new(args: &[String]) -> i32 {
warn!("Failed to update .gitignore: {e}");
}
println!(
"Project `{}` successfully created\n",
project_name.style(SUCCESS_ST)
);
sprintln!(BOLD_GREEN, "Next step:");
sprintln!(BOLD_CYAN, " cd {}\n dcr run", project_name);
crate::utils::output::success(format!("Created project `{project_name}`"));
crate::utils::output::info(format!("Next steps:\n cd {project_name}\n dcr run"));
0
}

View File

@@ -22,9 +22,7 @@ use crate::cli::flags::parse_build_run_flags;
use crate::core::build_config::Config;
use crate::core::runner::run_binary;
use crate::utils::build::{normalize_target_os, parse_version_info, substitute_vars};
use crate::utils::cli_styles::{BOLD_GREEN, Colorize, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST};
use crate::utils::fs::{find_project_root, with_dir};
use crate::utils::log::sprintln;
use std::{path::Path, process::Command};
/// Retrieves the run command from the config, preferring target-specific,
@@ -67,24 +65,19 @@ fn get_run_cmd(
/// Process exit code from the built binary / `run.cmd`, or non-zero on build/setup failure.
pub fn run(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(
HELP_EXAMPLES_ST,
" dcr run [--debug | --release] [--target <triple>] [--force] [--clean] [--verbose] [-- <args>...]"
);
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Builds and runs the project. Only available for kind = \"bin\".");
println!(" Arguments after `--` are passed to the built binary (cargo-style).");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --debug Run with debug profile (default)");
println!(" --release Run with release profile");
println!(" --target <triple> Cross-compile for the given target");
println!(" --force Force a full rebuild");
println!(" --clean Clean before building");
println!(" --verbose Print detailed build output");
println!(" -- <args>... Arguments forwarded to the binary");
let mut help = crate::utils::help::HelpBuilder::new("dcr run");
help.section("USAGE").example("dcr run [--debug | --release] [--target <triple>] [--force] [--clean] [--verbose] [-- <args>...]");
help.description("Builds and runs the project. Only available for kind = \"bin\".")
.note("Arguments after `--` are passed to the built binary (cargo-style).");
help.section("OPTIONS")
.item("--debug", "Run with debug profile (default)")
.item("--release", "Run with release profile")
.item("--target <triple>", "Cross-compile for the given target")
.item("--force", "Force a full rebuild")
.item("--clean", "Clean before building")
.item("--verbose", "Print detailed build output")
.item("-- <args>...", "Arguments forwarded to the binary");
help.render();
return 0;
}
@@ -126,11 +119,7 @@ pub fn run(args: &[String]) -> i32 {
let build_status = build(&args_for_build(&flags));
if build_status == 0 {
let display = display_run_cmd(&cmd, &flags.bin_args);
println!(
" {} {}",
format!("{:<9}", "run").style(BOLD_GREEN),
display
);
crate::utils::output::status("run", display);
return run_shell_with_args(&cmd, &flags.bin_args);
}
return build_status;
@@ -266,19 +255,11 @@ fn run_project(
if build_status == 0 {
if let Some(cmd) = run_cmd {
let display = display_run_cmd(&cmd, &flags.bin_args);
println!(
" {} {}",
format!("{:<9}", "run").style(BOLD_GREEN),
display
);
crate::utils::output::status("run", display);
return Ok(run_shell_with_args(&cmd, &flags.bin_args));
}
let display = display_bin_run(&bin_path, &flags.bin_args);
println!(
" {} {}",
format!("{:<9}", "run").style(BOLD_GREEN),
display
);
crate::utils::output::status("run", display);
return Ok(run_binary(
project_name,
&flags.profile,

View File

@@ -15,33 +15,31 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use crate::utils::cli_styles::{HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST};
use crate::utils::log::sprintln;
/// Sets up DCR registries by loading the registry manager and printing the list of registries.
pub fn setup(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr setup");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Sets up DCR registries. Downloads and indexes");
println!(" package registries for dependency resolution.");
let mut help = crate::utils::help::HelpBuilder::new("dcr setup");
help.section("USAGE").example("dcr setup");
help.description("Show configured package registries and their priorities.");
help.render();
return 0;
}
println!("Setting up DCR registries...");
// Load and process the registries, returning 0 on success or 1 on error
match crate::core::registry::RegistryManager::load() {
Ok(manager) => {
println!("Loaded {} registries.", manager.config.registry.len());
let mut lines = vec![format!(
"Loaded {} registries (configured)",
manager.config.registry.len()
)];
for (name, reg) in manager.config.registry {
println!("- {}: {} (priority {})", name, reg.url, reg.priority);
lines.push(format!(" {name}: {} (priority {})", reg.url, reg.priority));
}
crate::say!("{}", lines.join("\n"));
0
}
Err(e) => {
println!("Error: {}", e);
crate::utils::output::error!("{e}");
1
}
}

View File

@@ -24,12 +24,7 @@ use crate::utils::build::{
get_config_opt, get_config_str, get_language_with_profile, get_list_with_profile,
get_string_with_profile, resolve_compiler, resolve_pkg_config_flags,
};
use crate::utils::cli_styles::{
BOLD_GREEN, Colorize, ERROR_ST, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST, SKIP_ST, SUCCESS_ST,
Style,
};
use crate::utils::fs::{find_project_root, with_dir};
use crate::utils::log::sprintln;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -40,22 +35,19 @@ pub fn test(args: &[String]) -> i32 {
let mut profile = "debug";
for arg in args {
if arg == "--help" {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(
HELP_EXAMPLES_ST,
" dcr test [--init] [--debug | --release]"
);
println!();
sprintln!(HELP_SECTION_TITLE_ST, "ALIASES:");
println!(" dcr tests");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Runs project tests and prints a unified testsuite report.");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "OPTIONS:");
println!(" --init Create tests/dcr_test.h in current project");
println!(" --debug Build and run tests with debug profile (default)");
println!(" --release Build and run tests with release profile");
let mut help = crate::utils::help::HelpBuilder::new("dcr test");
help.section("USAGE")
.example("dcr test [--init] [--debug | --release]");
help.section("ALIASES").example("dcr tests");
help.description("Runs project tests and prints a unified testsuite report.");
help.section("OPTIONS")
.item("--init", "Create tests/dcr_test.h in current project")
.item(
"--debug",
"Build and run tests with debug profile (default)",
)
.item("--release", "Build and run tests with release profile");
help.render();
return 0;
}
if arg == "--init" {
@@ -185,7 +177,7 @@ fn run_testsuite(profile: &str) -> Result<i32, String> {
stdout.push_str(&String::from_utf8_lossy(&out.stdout));
let stderr = String::from_utf8_lossy(&out.stderr);
if !stderr.trim().is_empty() {
eprint!("{}", stderr);
crate::utils::output::compiler_output("stderr", &stderr);
}
if !out.status.success() {
suite_success = false;
@@ -200,19 +192,19 @@ fn run_testsuite(profile: &str) -> Result<i32, String> {
for line in stdout.lines() {
let line = line.trim();
if let Some(name) = line.strip_prefix("[PASS] ") {
println!("{} {}", "[PASS]".style(SUCCESS_ST), name);
crate::utils::output::success(format!("Passed {name}"));
pass += 1;
parsed_any = true;
continue;
}
if let Some(name) = line.strip_prefix("[SKIP] ") {
println!("{} {}", "[SKIP]".style(SKIP_ST), name);
crate::utils::output::status("Skipped", name);
skip += 1;
parsed_any = true;
continue;
}
if let Some(name) = line.strip_prefix("[FAIL] ") {
println!("{} {}", "[FAIL]".style(ERROR_ST), name);
crate::utils::output::error(format!("Failed {name}"));
fail += 1;
parsed_any = true;
continue;
@@ -220,17 +212,17 @@ fn run_testsuite(profile: &str) -> Result<i32, String> {
if let Some((status, name)) = line.split_once('\t') {
match status {
"PASS" => {
println!("{} {}", "[PASS]".style(SUCCESS_ST), name);
crate::utils::output::success(format!("Passed {name}"));
pass += 1;
parsed_any = true;
}
"SKIP" => {
println!("{} {}", "[SKIP]".style(SKIP_ST), name);
crate::utils::output::status("Skipped", name);
skip += 1;
parsed_any = true;
}
"FAIL" => {
println!("{} {}", "[FAIL]".style(ERROR_ST), name);
crate::utils::output::error(format!("Failed {name}"));
fail += 1;
parsed_any = true;
}
@@ -242,11 +234,11 @@ fn run_testsuite(profile: &str) -> Result<i32, String> {
if !parsed_any {
if suite_success {
for name in &declared {
println!("{} {}", "[PASS]".style(SUCCESS_ST), name);
crate::utils::output::success(format!("Passed {name}"));
}
pass = declared.len() as i32;
} else {
println!("{} testsuite", "[FAIL]".style(ERROR_ST));
crate::utils::output::error("testsuite failed");
fail = 1;
}
}
@@ -257,16 +249,10 @@ fn run_testsuite(profile: &str) -> Result<i32, String> {
declared.len() as i32
};
println!();
sprintln!(BOLD_GREEN, "=====================");
sprintln!(BOLD_GREEN, " Testsuite summary ");
sprintln!(BOLD_GREEN, "=====================");
println!("TOTAL: {}", total);
print_field("PASS", pass, SUCCESS_ST);
print_field("SKIP", skip, SKIP_ST);
print_field("FAIL", fail, ERROR_ST);
sprintln!(BOLD_GREEN, "=====================");
sprintln!(BOLD_GREEN, "=====================");
crate::utils::output::status(
"test result:",
format!("{pass} passed; {fail} failed; {skip} skipped; {total} total"),
);
if fail > 0 || !suite_success {
return Ok(1);
@@ -364,10 +350,10 @@ fn link_test_binary(
/// Prints the stdout and stderr from a command execution.
fn print_command_output(output: &std::process::Output) {
if !output.stdout.is_empty() {
print!("{}", String::from_utf8_lossy(&output.stdout));
crate::utils::output::write_stdout(&format!("{}", String::from_utf8_lossy(&output.stdout)));
}
if !output.stderr.is_empty() {
eprint!("{}", String::from_utf8_lossy(&output.stderr));
crate::utils::output::compiler_output("stderr", &String::from_utf8_lossy(&output.stderr));
}
}
@@ -376,10 +362,10 @@ fn ensure_test_header() -> Result<(), String> {
fs::create_dir_all("./tests").map_err(|_| "Failed to create tests/".to_string())?;
fs::write("./tests/dcr_test.h", FILE_DCR_TEST_H)
.map_err(|_| "Failed to write tests/dcr_test.h".to_string())?;
println!("Created tests/dcr_test.h");
crate::utils::output::created("tests/dcr_test.h");
fs::write("./tests/test.c", FILE_TEST_C)
.map_err(|_| "Failed to write tests/test.c".to_string())?;
println!("Created tests/test.c");
crate::utils::output::created("tests/test.c");
Ok(())
}
@@ -399,12 +385,3 @@ fn extract_test_names_path(path: &Path) -> Vec<String> {
}
names
}
/// Prints a test result field with color coding.
fn print_field(label: &str, value: i32, style: Style) {
if value == 0 {
println!("{}: {}", label, value);
} else {
sprintln!(style, "{}: {}", label, value);
}
}

View File

@@ -18,8 +18,6 @@
use crate::prelude::*;
use crate::core::build_config::Config;
use crate::utils::cli_styles::{BOLD_CYAN, HELP_EXAMPLES_ST, HELP_SECTION_TITLE_ST};
use crate::utils::log::sprintln;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use toml::Value;
@@ -30,11 +28,10 @@ use toml::Value;
/// Otherwise, it parses the dcr.toml configuration and prints the dependency tree.
pub fn tree(args: &[String]) -> i32 {
if args.first().is_some_and(|a| a == "--help") {
sprintln!(HELP_SECTION_TITLE_ST, "USAGE:");
sprintln!(HELP_EXAMPLES_ST, " dcr tree");
println!();
sprintln!(HELP_SECTION_TITLE_ST, "DESCRIPTION:");
println!(" Displays the dependency tree of the current project.");
let mut help = crate::utils::help::HelpBuilder::new("dcr tree");
help.section("USAGE").example("dcr tree");
help.description("Displays the dependency tree of the current project.");
help.render();
return 0;
}
@@ -56,7 +53,7 @@ pub fn tree(args: &[String]) -> i32 {
.and_then(|v| v.as_str())
.unwrap_or("0.0.0");
sprintln!(BOLD_CYAN, "{} v{}", name, version);
crate::utils::output::write_stdout_raw(&format!("{} v{}\n", name, version));
let mut seen = HashSet::new();
seen.insert(name.to_string());
@@ -138,7 +135,10 @@ fn print_deps(
};
}
println!("{}{}{}{}", prefix, connector, name, version);
crate::utils::output::write_stdout_raw(&format!(
"{}{}{}{}\n",
prefix, connector, name, version
));
// Recurse only if sub-dependencies exist and this dep hasn't been seen
if let Some(s_deps) = sub_deps

View File

@@ -609,10 +609,14 @@ fn build_project_at_inner(
tc_as.as_deref(),
);
let resolved_linker = resolve_tool("DCR_LD", tc_ld.as_deref());
prepend_clang_target_flag(&mut build_cflags, build_target, &resolved_compiler);
// The host triple is still used for native target-specific configuration and
// artifact paths, but native Clang must select the platform SDK implicitly.
// Pass --target only when the user/config explicitly requested a target.
let compiler_target = if explicit_target { build_target } else { None };
prepend_clang_target_flag(&mut build_cflags, compiler_target, &resolved_compiler);
prepend_clang_target_flag(
&mut build_ldflags,
build_target,
compiler_target,
resolved_linker.as_deref().unwrap_or(&resolved_compiler),
);
let resolved_archiver = resolve_tool("DCR_AR", tc_ar.as_deref());

View File

@@ -29,10 +29,15 @@ use prelude::*;
/// appropriate subcommand handler. With fewer than two arguments, shows help and
/// exits with code 0; otherwise exits with the handler's exit code.
fn main() {
let args = match utils::output::configure(std::env::args().collect()) {
Ok(args) => args,
Err(message) => {
utils::output::emit("error", &message);
std::process::exit(2);
}
};
utils::log::init();
let args: Vec<String> = std::env::args().collect();
if args.len() < 2 {
std::process::exit(cli::help::help());
}

View File

@@ -58,11 +58,11 @@ pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
}
#[cfg(target_os = "macos")]
{
return macos::bin_path(profile, name, target_dir);
macos::bin_path(profile, name, target_dir)
}
#[cfg(target_os = "windows")]
{
return windows::bin_path(profile, name, target_dir);
windows::bin_path(profile, name, target_dir)
}
#[cfg(any(
target_os = "freebsd",
@@ -71,7 +71,7 @@ pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
))]
{
return bsd::bin_path(profile, name, target_dir);
bsd::bin_path(profile, name, target_dir)
}
#[cfg(not(any(
target_os = "linux",
@@ -83,7 +83,7 @@ pub fn bin_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
)))]
{
return posix::bin_path(profile, name, target_dir);
posix::bin_path(profile, name, target_dir)
}
}
@@ -97,11 +97,11 @@ pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
}
#[cfg(target_os = "macos")]
{
return macos::elf_path(profile, name, target_dir);
macos::elf_path(profile, name, target_dir)
}
#[cfg(target_os = "windows")]
{
return windows::elf_path(profile, name, target_dir);
windows::elf_path(profile, name, target_dir)
}
#[cfg(any(
target_os = "freebsd",
@@ -110,7 +110,7 @@ pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
))]
{
return bsd::elf_path(profile, name, target_dir);
bsd::elf_path(profile, name, target_dir)
}
#[cfg(not(any(
target_os = "linux",
@@ -122,7 +122,7 @@ pub fn elf_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
)))]
{
return posix::elf_path(profile, name, target_dir);
posix::elf_path(profile, name, target_dir)
}
}
@@ -136,11 +136,11 @@ pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
}
#[cfg(target_os = "macos")]
{
return macos::efi_path(profile, name, target_dir);
macos::efi_path(profile, name, target_dir)
}
#[cfg(target_os = "windows")]
{
return windows::efi_path(profile, name, target_dir);
windows::efi_path(profile, name, target_dir)
}
#[cfg(any(
target_os = "freebsd",
@@ -149,7 +149,7 @@ pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
))]
{
return bsd::efi_path(profile, name, target_dir);
bsd::efi_path(profile, name, target_dir)
}
#[cfg(not(any(
target_os = "linux",
@@ -161,7 +161,7 @@ pub fn efi_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
)))]
{
return posix::efi_path(profile, name, target_dir);
posix::efi_path(profile, name, target_dir)
}
}
@@ -175,11 +175,11 @@ pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
}
#[cfg(target_os = "macos")]
{
return macos::lib_path(profile, name, target_dir);
macos::lib_path(profile, name, target_dir)
}
#[cfg(target_os = "windows")]
{
return windows::lib_path(profile, name, target_dir);
windows::lib_path(profile, name, target_dir)
}
#[cfg(any(
target_os = "freebsd",
@@ -188,7 +188,7 @@ pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
))]
{
return bsd::lib_path(profile, name, target_dir);
bsd::lib_path(profile, name, target_dir)
}
#[cfg(not(any(
target_os = "linux",
@@ -200,7 +200,7 @@ pub fn lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> String {
target_os = "dragonfly"
)))]
{
return posix::lib_path(profile, name, target_dir);
posix::lib_path(profile, name, target_dir)
}
}
@@ -214,11 +214,11 @@ pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> S
}
#[cfg(target_os = "macos")]
{
return macos::shared_lib_path(profile, name, target_dir);
macos::shared_lib_path(profile, name, target_dir)
}
#[cfg(target_os = "windows")]
{
return windows::shared_lib_path(profile, name, target_dir);
windows::shared_lib_path(profile, name, target_dir)
}
#[cfg(any(
target_os = "freebsd",
@@ -227,7 +227,7 @@ pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> S
target_os = "dragonfly"
))]
{
return bsd::shared_lib_path(profile, name, target_dir);
bsd::shared_lib_path(profile, name, target_dir)
}
#[cfg(not(any(
target_os = "linux",
@@ -239,6 +239,6 @@ pub fn shared_lib_path(profile: &str, name: &str, target_dir: Option<&str>) -> S
target_os = "dragonfly"
)))]
{
return posix::shared_lib_path(profile, name, target_dir);
posix::shared_lib_path(profile, name, target_dir)
}
}

View File

@@ -782,6 +782,13 @@ mod tests {
assert_eq!(flags, vec!["-O2".to_string()]);
}
#[test]
fn native_clang_target_is_not_added_without_explicit_target() {
let mut flags = vec!["-O3".to_string()];
prepend_clang_target_flag(&mut flags, None, "clang");
assert_eq!(flags, vec!["-O3".to_string()]);
}
/// Test for parse_version_parts function.
#[test]
fn parse_version_parts() {

124
src/utils/help.rs Normal file
View File

@@ -0,0 +1,124 @@
use super::cli_styles::{BOLD_CYAN, BOLD_GREEN, Colorize};
use super::output;
pub struct HelpBuilder {
title: String,
description: Option<String>,
sections: Vec<Section>,
tip: Option<String>,
}
pub struct HelpSection<'a> {
help: &'a mut HelpBuilder,
index: usize,
}
struct Section {
title: String,
entries: Vec<(String, String)>,
}
impl HelpBuilder {
pub fn new(title: impl Into<String>) -> Self {
Self {
title: title.into(),
description: None,
sections: Vec::new(),
tip: None,
}
}
pub fn description(&mut self, text: impl Into<String>) -> &mut Self {
self.description = Some(text.into());
self
}
/// Add an example to a dedicated `EXAMPLES` section.
pub fn example(&mut self, text: impl Into<String>) -> &mut Self {
let text = text.into();
if let Some(section) = self.sections.iter_mut().find(|s| s.title == "EXAMPLES") {
section.entries.push((text, String::new()));
} else {
self.sections.push(Section {
title: "EXAMPLES".to_owned(),
entries: vec![(text, String::new())],
});
}
self
}
pub fn note(&mut self, text: impl Into<String>) -> &mut Self {
let text = text.into();
match &mut self.description {
Some(description) if !description.is_empty() => {
description.push('\n');
description.push_str(&text);
}
Some(description) => description.push_str(&text),
None => self.description = Some(text),
}
self
}
pub fn section(&mut self, title: impl Into<String>) -> HelpSection<'_> {
self.sections.push(Section {
title: title.into(),
entries: Vec::new(),
});
HelpSection {
index: self.sections.len() - 1,
help: self,
}
}
pub fn tip(&mut self, text: impl Into<String>) -> &mut Self {
self.tip = Some(text.into());
self
}
pub fn render(&self) {
if output::mode() == output::Mode::Json {
let sections: Vec<_> = self.sections.iter().map(|s| serde_json::json!({"title": s.title, "items": s.entries.iter().map(|(n,d)| serde_json::json!({"name":n,"description":d})).collect::<Vec<_>>() })).collect();
let event = serde_json::json!({
"schema_version": 1,
"event": "help",
"title": self.title,
"description": self.description,
"sections": sections,
"tip": self.tip
});
output::write_stdout_raw(&format!("{event}\n"));
return;
}
let mut rendered = String::new();
rendered.push_str(&format!("{}\n", self.title.style(BOLD_GREEN)));
if let Some(d) = &self.description {
rendered.push_str(&format!("{}\n\n", d));
}
for s in &self.sections {
rendered.push_str(&format!("{}:\n", s.title.style(BOLD_GREEN)));
let width = s.entries.iter().map(|(n, _)| n.len()).max().unwrap_or(0);
for (n, d) in &s.entries {
if d.is_empty() {
rendered.push_str(&format!(" {}\n", n.style(BOLD_CYAN)));
} else {
rendered.push_str(&format!(
" {} {d}\n",
format!("{n:<width$}").style(BOLD_CYAN)
));
}
}
rendered.push('\n');
}
if let Some(t) = &self.tip {
rendered.push_str(&format!("{}:\n {t}\n", "TIP".style(BOLD_GREEN)));
}
output::write_stdout(&rendered);
}
}
impl HelpSection<'_> {
pub fn item(self, name: impl Into<String>, description: impl Into<String>) -> Self {
self.help.sections[self.index]
.entries
.push((name.into(), description.into()));
self
}
pub fn example(self, command: impl Into<String>) -> Self {
self.item(command, "")
}
}

View File

@@ -15,55 +15,37 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
use env_logger::{Builder, WriteStyle};
use log::{Level, LevelFilter};
use owo_colors::OwoColorize;
use std::io::Write;
use log::{LevelFilter, Log, Metadata, Record};
struct CliLogger;
impl Log for CliLogger {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.level().to_level_filter() <= log::max_level()
}
fn log(&self, record: &Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let kind = match record.level() {
log::Level::Warn => "warning",
log::Level::Error => "error",
log::Level::Info => "info",
log::Level::Debug => "debug",
log::Level::Trace => "trace",
};
crate::utils::output::emit(kind, &record.args().to_string());
}
fn flush(&self) {}
}
// A function for initializing the logging lib with basic settings.
pub fn init() {
Builder::new()
.filter_level(LevelFilter::Warn)
.write_style(WriteStyle::Auto)
.format(|buf, record| {
let colored_lv = match record.level() {
Level::Error => record.level().red().to_string(),
Level::Warn => record.level().yellow().to_string(),
Level::Info => record.level().default_color().to_string(),
_ => record.level().purple().italic().to_string(),
};
writeln!(
buf,
"{}: {}",
colored_lv.to_lowercase().bold(),
record.args(),
)
})
.init();
static LOGGER: CliLogger = CliLogger;
log::set_logger(&LOGGER).expect("logger already initialized");
log::set_max_level(if crate::utils::output::verbose() {
LevelFilter::Debug
} else {
LevelFilter::Info
});
}
/// Prints message to stdout with the given `Style`. Copy of println! macro
#[allow(unused_macros)]
macro_rules! sprintln {
($style:expr, $($arg:tt)*) => {
{
use ::owo_colors::OwoColorize as _;
println!("{}", format!($($arg)*).style($style));
}
};
}
/// Prints message to stdout with the given `Style`. Copy of print! macro
#[allow(unused_macros)]
macro_rules! sprint {
($style:expr, $($arg:tt)*) => {
{
use ::owo_colors::OwoColorize as _;
print!("{}", format!($($arg)*).style($style));
}
};
}
#[allow(unused_imports)]
pub(crate) use {sprint, sprintln};

View File

@@ -30,3 +30,5 @@ pub mod log;
/// Text processing and manipulation helpers.
pub mod cli_styles;
pub mod help;
pub mod output;

535
src/utils/output.rs Normal file
View File

@@ -0,0 +1,535 @@
//! Central rendering policy for user-facing CLI events.
use std::io::{self, IsTerminal, Write};
use std::sync::OnceLock;
#[derive(Clone, Copy, Default, PartialEq)]
pub enum Mode {
#[default]
Human,
Plain,
Json,
}
#[derive(Default)]
pub struct Options {
pub mode: Mode,
pub quiet: bool,
pub verbose: bool,
pub color: Option<bool>,
}
static OPTIONS: OnceLock<Options> = OnceLock::new();
/// Consume global options without touching arguments after `--`.
pub fn configure(args: Vec<String>) -> Result<Vec<String>, String> {
let mut options = Options::default();
let mut remaining = Vec::new();
let mut args = args.into_iter();
if let Some(program) = args.next() {
remaining.push(program);
}
while let Some(arg) = args.next() {
if arg == "--" {
remaining.push(arg);
remaining.extend(args);
break;
}
match arg.as_str() {
"--quiet" => options.quiet = true,
"--verbose" => options.verbose = true,
"--output" | "--color" => {
let value = args
.next()
.ok_or_else(|| format!("{arg} requires a value"))?;
set_option(&mut options, &arg, &value)?;
}
_ if arg.starts_with("--output=") || arg.starts_with("--color=") => {
let (key, value) = arg.split_once('=').unwrap();
set_option(&mut options, key, value)?;
}
_ => {
remaining.push(arg);
remaining.extend(args);
break;
}
}
}
OPTIONS
.set(options)
.map_err(|_| "output already configured".to_owned())?;
// Help is styled before it reaches the output writer. Keep owo-colors'
// global decision in sync with DCR's explicit color policy; otherwise
// `--color always` can color the DCR banner but leave help sections plain.
let configured = OPTIONS.get().expect("output options were just configured");
let force_color = configured.color.or_else(|| {
if std::env::var_os("NO_COLOR").is_some() {
Some(false)
} else {
None
}
});
if let Some(enabled) = force_color {
owo_colors::set_override(enabled);
}
Ok(remaining)
}
fn set_option(options: &mut Options, key: &str, value: &str) -> Result<(), String> {
match (key, value) {
("--output", "human") => options.mode = Mode::Human,
("--output", "plain") => options.mode = Mode::Plain,
("--output", "json") => options.mode = Mode::Json,
("--color", "auto") => options.color = None,
("--color", "always") => options.color = Some(true),
("--color", "never") => options.color = Some(false),
_ => return Err(format!("invalid value `{value}` for {key}")),
}
Ok(())
}
pub fn emit(kind: &str, message: &str) {
let defaults = Options::default();
let options = OPTIONS.get().unwrap_or(&defaults);
if options.quiet && !is_quiet_visible(kind) {
return;
}
if matches!(kind, "debug" | "trace") && !options.verbose {
return;
}
let mut stderr = io::stderr().lock();
let _ = render(
&mut stderr,
options,
kind,
message,
io::stderr().is_terminal(),
);
}
/// Emit a structured event while retaining the human-readable diagnostic form.
pub fn emit_event(event: &str, fields: serde_json::Value, human: &str) {
let defaults = Options::default();
let options = OPTIONS.get().unwrap_or(&defaults);
if options.quiet && !is_quiet_visible(event) {
return;
}
if options.mode == Mode::Json {
let mut object = match fields {
serde_json::Value::Object(object) => object,
_ => serde_json::Map::new(),
};
object.insert("schema_version".into(), serde_json::json!(1));
object.insert("event".into(), serde_json::json!(event));
let mut stderr = io::stderr().lock();
let _ = writeln!(stderr, "{}", serde_json::Value::Object(object));
} else if event == "compiler_output" {
emit("compiler_output", human);
} else {
emit("build", human);
}
}
fn is_quiet_visible(kind: &str) -> bool {
matches!(kind, "error" | "warning" | "compiler_output")
}
pub fn mode() -> Mode {
OPTIONS.get().map_or(Mode::Human, |options| options.mode)
}
pub fn verbose() -> bool {
OPTIONS.get().is_some_and(|options| options.verbose)
}
pub fn colors_enabled(terminal: bool) -> bool {
let defaults = Options::default();
let options = OPTIONS.get().unwrap_or(&defaults);
colors_enabled_for(options, terminal)
}
fn colors_enabled_for(options: &Options, terminal: bool) -> bool {
options.mode == Mode::Human
&& options
.color
.unwrap_or(terminal && std::env::var_os("NO_COLOR").is_none())
}
pub fn progress_label(action: &str, subject: &str) -> String {
let text = format!("{action:<12} {subject}");
if !colors_enabled(true) {
return text;
}
let code = match action.to_ascii_lowercase().as_str() {
"project" | "target" | "dep" | "pack" | "ready" | "profile" => 36,
_ => 32,
};
format!("\x1b[1;{code}m{action:>12}\x1b[0m {subject}")
}
/// A compact fluent writer for user-facing status output.
///
/// Commands can build a complete message without repeating the output macro:
/// `output::ui().status("Compiling", name).detail("release").finish();`
#[allow(dead_code)]
pub struct Ui {
lines: Vec<String>,
}
#[allow(dead_code)]
pub fn ui() -> Ui {
Ui { lines: Vec::new() }
}
pub fn say(message: impl AsRef<str>) {
write_stdout(&format!("{}\n", message.as_ref()));
}
pub fn status(action: impl AsRef<str>, subject: impl AsRef<str>) {
emit(
"status",
&format!("{} {}", action.as_ref(), subject.as_ref()),
);
}
pub fn success(message: impl AsRef<str>) {
emit("success", message.as_ref());
}
pub fn warning(message: impl AsRef<str>) {
emit("warning", message.as_ref());
}
pub fn info(message: impl AsRef<str>) {
emit("info", message.as_ref());
}
pub fn error(message: impl AsRef<str>) {
emit("error", message.as_ref());
}
pub fn created(subject: impl AsRef<str>) {
emit("success", &format!("Created {}", subject.as_ref()));
}
pub fn removed(subject: impl AsRef<str>) {
emit("success", &format!("Removed {}", subject.as_ref()));
}
pub fn compiler_output(stream: &str, text: &str) {
if mode() == Mode::Json {
emit_event(
"compiler_output",
serde_json::json!({"stream": stream, "text": text}),
"",
);
} else if stream == "stderr" {
emit("compiler_output", text);
} else {
write_stdout(text);
}
}
#[macro_export]
macro_rules! say { () => { $crate::utils::output::say("") }; ($($arg:tt)*) => { $crate::utils::output::say(format!($($arg)*)) }; }
#[macro_export]
macro_rules! status { ($action:expr, $($arg:tt)*) => { $crate::utils::output::status($action, format!($($arg)*)) }; }
#[macro_export]
macro_rules! success { ($($arg:tt)*) => { $crate::utils::output::success(format!($($arg)*)) }; }
impl Ui {
#[allow(dead_code)]
pub fn status(mut self, action: impl Into<String>, subject: impl Into<String>) -> Self {
self.lines
.push(format!("{} {}", action.into(), subject.into()));
self
}
#[allow(dead_code)]
pub fn detail(mut self, detail: impl Into<String>) -> Self {
if let Some(line) = self.lines.last_mut() {
line.push_str(&format!(" ({})", detail.into()));
}
self
}
#[allow(dead_code)]
pub fn item(mut self, item: impl Into<String>) -> Self {
self.lines.push(format!(" {}", item.into()));
self
}
#[allow(dead_code)]
pub fn finish(self) {
emit("status", &self.lines.join("\n"));
}
}
#[macro_export]
macro_rules! output_warning {
($($arg:tt)*) => { $crate::utils::output::emit("warning", &format!($($arg)*)) };
}
#[macro_export]
macro_rules! output_error {
($($arg:tt)*) => { $crate::utils::output::emit("error", &format!($($arg)*)) };
}
#[macro_export]
macro_rules! output_info {
($($arg:tt)*) => { $crate::utils::output::emit("info", &format!($($arg)*)) };
}
#[macro_export]
macro_rules! output_success {
($($arg:tt)*) => { $crate::utils::output::emit("success", &format!($($arg)*)) };
}
// Namespaced aliases avoid collisions with the technical `log` macros.
#[allow(unused_imports)]
pub use crate::{
output_error as error, output_info as info, output_success as success,
output_warning as warning,
};
/// User-facing stdout macros. Data/help output belongs on stdout; diagnostics
/// and build status use `emit` on stderr.
#[macro_export]
macro_rules! output_println {
() => { $crate::utils::output::write_stdout("\n") };
($($arg:tt)*) => { $crate::utils::output::write_stdout(&format!("{}\n", format_args!($($arg)*))) };
}
#[macro_export]
macro_rules! output_print {
($($arg:tt)*) => { $crate::utils::output::write_stdout(&format!($($arg)*)) };
}
pub fn write_stdout(text: &str) {
let defaults = Options::default();
let options = OPTIONS.get().unwrap_or(&defaults);
let colored = colors_enabled(io::stdout().is_terminal());
let has_ansi = text.contains('\x1b');
let rendered = if colored || !has_ansi {
text.to_owned()
} else {
strip_ansi(text)
};
let mut stdout = io::stdout().lock();
let result = if options.mode == Mode::Json {
writeln!(
stdout,
"{}",
serde_json::json!({"schema_version": 1, "event": "output", "text": rendered})
)
} else {
stdout.write_all(rendered.as_bytes())
};
if let Err(error) = result
&& error.kind() == io::ErrorKind::BrokenPipe
{
std::process::exit(0);
}
}
pub fn write_stdout_raw(text: &str) {
let mut stdout = io::stdout().lock();
if let Err(error) = stdout.write_all(text.as_bytes())
&& error.kind() == io::ErrorKind::BrokenPipe
{
std::process::exit(0);
}
}
fn strip_ansi(text: &str) -> String {
let mut chars = text.chars().peekable();
let mut clean = String::new();
while let Some(ch) = chars.next() {
if ch == '\x1b' && chars.peek() == Some(&'[') {
chars.next();
for ch in chars.by_ref() {
if ('@'..='~').contains(&ch) {
break;
}
}
} else {
clean.push(ch);
}
}
clean
}
#[macro_export]
macro_rules! output_sprintln {
($style:expr, $($arg:tt)*) => {{
use $crate::utils::cli_styles::Colorize as _;
$crate::output_println!("{}", format!($($arg)*).style($style));
}};
}
/// Render a help heading through the same output policy as the rest of the CLI.
/// Keeping this separate from ordinary styled output makes help formatting easy
/// to change globally without touching every command.
#[macro_export]
macro_rules! output_help_heading {
($style:expr, $($arg:tt)*) => {{
use $crate::utils::cli_styles::Colorize as _;
$crate::utils::output::write_stdout(&format!("{}\n", format!($($arg)*).style($style)));
}};
}
#[macro_export]
macro_rules! output_sprint {
($style:expr, $($arg:tt)*) => {{
use $crate::utils::cli_styles::Colorize as _;
$crate::output_print!("{}", format!($($arg)*).style($style));
}};
}
fn render(
writer: &mut impl Write,
options: &Options,
kind: &str,
message: &str,
terminal: bool,
) -> io::Result<()> {
if options.mode == Mode::Json {
return writeln!(
writer,
"{}",
serde_json::json!({
"schema_version": 1, "event": kind, "message": message
})
);
}
let color = colors_enabled_for(options, terminal);
if kind == "compiler_output" {
let message = strip_ansi(message);
for line in message.lines() {
let code = if line.contains("error:") || line.contains("fatal error") {
31
} else if line.contains("warning:") {
33
} else if line.contains("note:") {
36
} else {
37
};
if color {
writeln!(writer, "\x1b[{code}m{line}\x1b[0m")?;
} else {
writeln!(writer, "{line}")?;
}
}
return Ok(());
}
if matches!(kind, "status" | "success" | "build") {
let message = strip_ansi(message);
let (action, subject) = message.split_once(' ').unwrap_or((&message, ""));
if color {
let code = match action.to_ascii_lowercase().as_str() {
"project" | "target" | "dep" | "pack" | "ready" | "profile" => 36,
"compile" | "compiling" | "created" | "creating" | "finished" | "done"
| "removed" | "initialized" | "added" | "checked" | "skipped" | "format"
| "formatting" | "lint" | "linting" | "run" | "up" | "loaded" => 32,
_ if kind == "success" => 32,
_ => 36,
};
return writeln!(writer, "\x1b[1;{code}m{action:>12}\x1b[0m {subject}");
}
return writeln!(writer, "{action:>12} {subject}");
}
if color {
let code = match kind {
"error" => 31,
"warning" => 33,
_ => 36,
};
writeln!(writer, "\x1b[1;{code}m{kind}:\x1b[0m {message}")
} else {
writeln!(writer, "{kind}: {}", strip_ansi(message))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn human_colors_status_action_and_diagnostics() {
let options = Options {
color: Some(true),
..Options::default()
};
for (kind, message, expected) in [
(
"status",
"Compiling hello",
"\x1b[1;32m Compiling\x1b[0m hello\n",
),
(
"success",
"Created project",
"\x1b[1;32m Created\x1b[0m project\n",
),
("warning", "notice", "\x1b[1;33mwarning:\x1b[0m notice\n"),
("error", "failed", "\x1b[1;31merror:\x1b[0m failed\n"),
] {
let mut bytes = Vec::new();
render(&mut bytes, &options, kind, message, false).unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), expected);
}
}
#[test]
fn disabled_color_strips_existing_styles() {
let options = Options {
color: Some(false),
..Options::default()
};
let mut bytes = Vec::new();
render(
&mut bytes,
&options,
"warning",
"\x1b[32mnotice\x1b[0m",
true,
)
.unwrap();
assert_eq!(String::from_utf8(bytes).unwrap(), "warning: notice\n");
}
#[test]
fn human_colors_compiler_diagnostics_by_severity() {
let options = Options {
color: Some(true),
..Options::default()
};
let mut bytes = Vec::new();
render(
&mut bytes,
&options,
"compiler_output",
"file.c:1:1: warning: unused value\nfile.c:2:1: error: invalid token",
true,
)
.unwrap();
let output = String::from_utf8(bytes).unwrap();
assert!(output.contains("\x1b[33mfile.c:1:1: warning: unused value\x1b[0m"));
assert!(output.contains("\x1b[31mfile.c:2:1: error: invalid token\x1b[0m"));
}
#[test]
fn plain_never_contains_ansi() {
let mut bytes = Vec::new();
let options = Options {
mode: Mode::Plain,
color: Some(true),
..Options::default()
};
render(&mut bytes, &options, "error", "compiler missing", true).unwrap();
assert_eq!(
String::from_utf8(bytes).unwrap(),
"error: compiler missing\n"
);
}
#[test]
fn json_is_versioned_and_escapes_multiline_messages() {
let mut bytes = Vec::new();
let options = Options {
mode: Mode::Json,
..Options::default()
};
render(&mut bytes, &options, "warning", "first\nsecond", false).unwrap();
let event: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(event["schema_version"], 1);
assert_eq!(event["message"], "first\nsecond");
}
}

View File

@@ -20,10 +20,15 @@ fn dcr_test_runs_without_sandbox_dependency() {
assert!(out.status.success(), "dcr test should succeed");
let stdout = String::from_utf8_lossy(&out.stdout);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stdout.contains("TOTAL: 1"), "TOTAL summary line missing");
assert!(
stdout.contains("PASS: 1"),
"PASS summary line missing\nstdout:\n{}\nstderr:\n{}",
stderr.contains("test result: 1 passed; 0 failed; 0 skipped; 1 total"),
"test result summary line missing\nstdout:\n{}\nstderr:\n{}",
stdout,
stderr
);
assert!(
stdout.contains("PASS") || stderr.contains("Passed"),
"test pass output missing\nstdout:\n{}\nstderr:\n{}",
stdout,
stderr
);