Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 78 additions & 10 deletions qlty-cli/src/commands/coverage/publish.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use clap::Args;
use console::style;
use indicatif::HumanBytes;
use num_format::{Locale, ToFormattedString as _};
use qlty_config::QltyConfig;
use qlty_config::{QltyConfig, Workspace};
use qlty_coverage::formats::Formats;
use qlty_coverage::print::{print_report_as_json, print_report_as_text};
use qlty_coverage::publish::{Plan, Planner, Processor, Reader, Report, Settings, Upload};
Expand Down Expand Up @@ -171,8 +171,9 @@ impl Publish {
load_auth_token(&self.token, self.project.as_deref())?
};

let workspace_root = Workspace::new().ok().map(|w| w.root);
let config = load_config();
let plan = Planner::new(&config, &settings).compute()?;
let plan = Planner::new(&config, &settings, workspace_root.clone()).compute()?;

self.validate_plan(&plan)?;

Expand All @@ -190,20 +191,31 @@ impl Publish {
}

if self.should_validate() {
let validator = Validator::new(self.validate_file_threshold);
let validator = Validator::new(self.validate_file_threshold, workspace_root);
let validation_result = validator.validate(&report)?;

match validation_result.status {
ValidationStatus::Valid => {}
ValidationStatus::Invalid => {
return Err(anyhow::anyhow!(
"Coverage validation failed: Only {:.2}% of files are present (threshold: {:.2}%)",
let mut error_msg = format!(
"Coverage validation failed: Only {:.2}% of files are present (threshold: {:.2}%)\n - {} files missing on disk",
validation_result.coverage_percentage,
validation_result.threshold
).into())
validation_result.threshold,
validation_result.files_missing
);
if validation_result.files_outside_workspace > 0 {
error_msg.push_str(&format!(
"\n - {} files outside repository",
validation_result.files_outside_workspace
));
}
return Err(anyhow::anyhow!(error_msg).into());
}
ValidationStatus::NoCoverageData => {
return Err(anyhow::anyhow!("Coverage validation failed: No coverage data found").into())
return Err(anyhow::anyhow!(
"Coverage validation failed: No coverage data found"
)
.into())
}
}
}
Expand Down Expand Up @@ -397,7 +409,9 @@ impl Publish {
if report.auto_path_fixing_enabled {
eprintln!(" Auto-path fixing: Enabled");
}
let total_files_count = report.found_files.len() + report.missing_files.len();
let total_files_count = report.found_files.len()
+ report.missing_files.len()
+ report.outside_workspace_files.len();

if report.excluded_files_count > 0 {
eprintln!(
Expand Down Expand Up @@ -502,7 +516,7 @@ impl Publish {
);

eprintln!();
} else if total_files_count > 0 {
} else if total_files_count > 0 && report.outside_workspace_files.is_empty() {
eprintln!(
" {}",
style(format!(
Expand All @@ -513,6 +527,60 @@ impl Publish {
);
}

// Display files outside repository (only if there are any)
if !report.outside_workspace_files.is_empty() {
let mut outside_files = report.outside_workspace_files.iter().collect::<Vec<_>>();
outside_files.sort();

let outside_percent = (outside_files.len() as f32 / total_files_count as f32) * 100.0;

eprintln!(
" {}",
style(format!(
"{} {} outside the repository ({:.1}%)",
outside_files.len().to_formatted_string(&Locale::en),
if outside_files.len() == 1 {
"path is"
} else {
"paths are"
},
outside_percent
))
.bold()
);

let (paths_to_show, show_all) = if self.verbose {
(outside_files.len(), true)
} else {
(std::cmp::min(20, outside_files.len()), false)
};

eprintln!(
"\n {}\n",
style("Files outside repository:").bold().yellow()
);

for path in outside_files.iter().take(paths_to_show) {
eprintln!(" {}", style(path.to_string()).yellow());
}

if !show_all && paths_to_show < outside_files.len() {
let remaining = outside_files.len() - paths_to_show;
eprintln!(
" {} {}",
style(format!(
"... and {} more",
remaining.to_formatted_string(&Locale::en)
))
.dim()
.yellow(),
style("(Use --verbose to see all)").dim()
);
}

eprintln!();
}

eprintln!();

// Get formatted numbers first
Expand Down
2 changes: 2 additions & 0 deletions qlty-coverage/src/publish/plan.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::Transformer;
use qlty_types::tests::v1::{CoverageMetadata, ReportFile};
use std::path::PathBuf;

#[derive(Debug, Clone, Default)]
pub struct Plan {
Expand All @@ -8,4 +9,5 @@ pub struct Plan {
pub transformers: Vec<Box<dyn Transformer>>,
pub skip_missing_files: bool,
pub auto_path_fixing_enabled: bool,
pub workspace_root: Option<PathBuf>,
}
16 changes: 10 additions & 6 deletions qlty-coverage/src/publish/planner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@ use qlty_config::QltyConfig;
use qlty_types::tests::v1::CoverageMetadata;
use qlty_types::tests::v1::ReferenceType;
use qlty_types::tests::v1::ReportFile;
use std::path::PathBuf;
use std::vec;
use time::OffsetDateTime;

#[derive(Debug, Clone)]
pub struct Planner {
config: QltyConfig,
settings: Settings,
workspace_root: Option<PathBuf>,
}

pub struct MetadataPlanner {
Expand All @@ -42,10 +44,11 @@ impl std::fmt::Debug for MetadataPlanner {
}

impl Planner {
pub fn new(config: &QltyConfig, settings: &Settings) -> Self {
pub fn new(config: &QltyConfig, settings: &Settings, workspace_root: Option<PathBuf>) -> Self {
Self {
config: config.clone(),
settings: settings.clone(),
workspace_root,
}
}

Expand All @@ -62,6 +65,7 @@ impl Planner {
transformers,
skip_missing_files: self.settings.skip_missing_files,
auto_path_fixing_enabled,
workspace_root: self.workspace_root.clone(),
})
}

Expand Down Expand Up @@ -607,7 +611,7 @@ mod tests {
..Default::default()
};

let planner = Planner::new(&config, &settings);
let planner = Planner::new(&config, &settings, None);
let metadata = CoverageMetadata::default();
let transformers = planner.compute_transformers(&metadata).unwrap();

Expand All @@ -632,7 +636,7 @@ mod tests {
..Default::default()
};

let planner = Planner::new(&config, &settings);
let planner = Planner::new(&config, &settings, None);
let metadata = CoverageMetadata::default();
let transformers = planner.compute_transformers(&metadata).unwrap();

Expand All @@ -657,7 +661,7 @@ mod tests {
..Default::default()
};

let planner = Planner::new(&config, &settings);
let planner = Planner::new(&config, &settings, None);
let metadata = CoverageMetadata::default();
let transformers = planner.compute_transformers(&metadata).unwrap();

Expand All @@ -683,7 +687,7 @@ mod tests {
..Default::default()
};

let planner = Planner::new(&config, &settings);
let planner = Planner::new(&config, &settings, None);
let metadata = CoverageMetadata::default();
let transformers = planner.compute_transformers(&metadata).unwrap();

Expand Down Expand Up @@ -778,7 +782,7 @@ mod tests {
};

let config = QltyConfig::default();
let planner = Planner::new(&config, &settings);
let planner = Planner::new(&config, &settings, None);
let plan = planner.compute().unwrap();

let file_coverage = FileCoverage {
Expand Down
36 changes: 30 additions & 6 deletions qlty-coverage/src/publish/processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ use crate::publish::{metrics::CoverageMetrics, Plan, Report, Results};
use anyhow::Result;
use qlty_types::tests::v1::FileCoverage;
use std::collections::HashSet;
use std::path::PathBuf;
use std::path::{Path, PathBuf};

pub struct Processor {
plan: Plan,
Expand Down Expand Up @@ -36,13 +36,20 @@ impl Processor {

let mut found_files = HashSet::new();
let mut missing_files = HashSet::new();
let mut outside_workspace_files = HashSet::new();

if self.plan.skip_missing_files {
transformed_file_coverages.retain(|file_coverage| {
match PathBuf::from(&file_coverage.path).try_exists() {
let path = PathBuf::from(&file_coverage.path);
match path.try_exists() {
Ok(true) => {
found_files.insert(file_coverage.path.clone());
true
if !self.is_within_workspace(&path) {
outside_workspace_files.insert(file_coverage.path.clone());
false
} else {
found_files.insert(file_coverage.path.clone());
true
}
}
_ => {
missing_files.insert(file_coverage.path.clone());
Expand All @@ -52,9 +59,14 @@ impl Processor {
});
} else {
for file_coverage in &transformed_file_coverages {
match PathBuf::from(&file_coverage.path).try_exists() {
let path = PathBuf::from(&file_coverage.path);
match path.try_exists() {
Ok(true) => {
found_files.insert(file_coverage.path.clone());
if !self.is_within_workspace(&path) {
outside_workspace_files.insert(file_coverage.path.clone());
} else {
found_files.insert(file_coverage.path.clone());
}
}
_ => {
missing_files.insert(file_coverage.path.clone());
Expand All @@ -74,11 +86,23 @@ impl Processor {
totals,
missing_files,
found_files,
outside_workspace_files,
excluded_files_count: ignored_paths_count,
auto_path_fixing_enabled: self.plan.auto_path_fixing_enabled,
})
}

fn is_within_workspace(&self, file_path: &Path) -> bool {
let Some(ref workspace_root) = self.plan.workspace_root else {
return true;
};

match (file_path.canonicalize(), workspace_root.canonicalize()) {
(Ok(canonical_file), Ok(canonical_root)) => canonical_file.starts_with(&canonical_root),
_ => false,
}
}
Copy link

Copilot AI Dec 2, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The is_within_workspace method is duplicated identically in validate.rs (lines 78-87). Consider extracting this logic into a shared utility function to maintain DRY (Don't Repeat Yourself) principle and ensure consistent behavior across the codebase.

Copilot uses AI. Check for mistakes.

fn transform(&self, file_coverage: FileCoverage) -> Option<FileCoverage> {
let mut file_coverage: Option<FileCoverage> = Some(file_coverage.clone());

Expand Down
3 changes: 3 additions & 0 deletions qlty-coverage/src/publish/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ pub struct Report {
#[serde(skip_serializing)]
pub missing_files: HashSet<String>,

#[serde(skip_serializing)]
pub outside_workspace_files: HashSet<String>,

pub totals: CoverageMetrics,
pub excluded_files_count: usize,

Expand Down
Loading
Loading