Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
1 change: 1 addition & 0 deletions crates/sqlbuild-cache/src/artifacts/_helpers/mod.rs
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
pub(crate) mod files;
pub(crate) mod sql_test_records;
70 changes: 70 additions & 0 deletions crates/sqlbuild-cache/src/artifacts/_helpers/sql_test_records.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
//! File facts of compiled SQL test artifacts: mtimes, content digests and safe paths.

use std::fs::Metadata;
use std::path::{Component, Path, PathBuf};

#[cfg(unix)]
use crate::artifacts::constants::NANOS_PER_SECOND;
use crate::artifacts::constants::SQL_EXTENSION;
use crate::digest::main::sha256_hex::sha256_hex;

/// Python's `os.stat_result.st_mtime_ns`.
#[cfg(unix)]
pub(crate) fn mtime_ns(metadata: &Metadata) -> Option<i64> {
use std::os::unix::fs::MetadataExt;
metadata
.mtime()
.checked_mul(NANOS_PER_SECOND)?
.checked_add(metadata.mtime_nsec())
}

/// Python's `os.stat_result.st_mtime_ns`.
#[cfg(not(unix))]
pub(crate) fn mtime_ns(metadata: &Metadata) -> Option<i64> {
let since = match metadata.modified() {
Ok(modified) => match modified.duration_since(std::time::UNIX_EPOCH) {
Ok(since) => since,
Err(_) => return None,
},
Err(_) => return None,
};
let Ok(nanos) = i64::try_from(since.as_nanos()) else {
return None;
};
Some(nanos)
}

/// The file's SHA-256 text, or `None` when it cannot be read, as Python's `OSError` is skipped.
pub(crate) fn file_sha256(path: &Path) -> Option<String> {
match std::fs::read(path) {
Ok(bytes) => Some(sha256_hex(&bytes)),
Err(_) => None,
}
}

/// The file's metadata, or `None` when it cannot be read.
pub(crate) fn metadata(path: &Path) -> Option<Metadata> {
let Ok(metadata) = std::fs::metadata(path) else {
return None;
};
Some(metadata)
}

/// The path as Python's `PurePath(path)` holds it: without `.` components.
pub(crate) fn normalized_relative_path(path: &Path) -> PathBuf {
path.components()
.filter(|component| !matches!(component, Component::CurDir))
.collect()
}

/// A relative `.sql` path that cannot leave the tests directory.
pub(crate) fn is_safe_relative_sql_path(path: &Path) -> bool {
!path.is_absolute()
&& !path.has_root()
&& !path
.components()
.any(|component| matches!(component, Component::ParentDir))
&& path
.extension()
.is_some_and(|extension| extension == SQL_EXTENSION)
}
3 changes: 3 additions & 0 deletions crates/sqlbuild-cache/src/artifacts/constants.rs
Original file line number Diff line number Diff line change
@@ -1,2 +1,5 @@
/// The mode new artifacts are created with before the process umask, as Python's `os.open`.
pub const NEW_FILE_MODE: u32 = 0o666;
/// The extension every compiled SQL test artifact carries.
pub(crate) const SQL_EXTENSION: &str = "sql";
pub(crate) const NANOS_PER_SECOND: i64 = 1_000_000_000;
3 changes: 3 additions & 0 deletions crates/sqlbuild-cache/src/artifacts/main/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
pub mod new_sql_test_record;
pub mod publish_staged;
pub mod remove_stale;
pub mod reusable_sql_test_record;
pub mod staged_files;
pub mod stored_sql_test_record;
pub mod write_artifacts;
34 changes: 34 additions & 0 deletions crates/sqlbuild-cache/src/artifacts/main/new_sql_test_record.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
//! The cache record of a newly written SQL test artifact.

use std::fs::Metadata;
use std::path::{Path, PathBuf};

use crate::artifacts::_helpers::sql_test_records::{
file_sha256, is_safe_relative_sql_path, metadata, mtime_ns, normalized_relative_path,
};
use crate::artifacts::models::SqlTestArtifactRecord;

/// The artifact's record, or `None` when it cannot be read or reused safely.
#[must_use]
pub fn new_sql_test_record(
tests_root: &Path,
artifact_path: &Path,
identity: &str,
) -> Option<SqlTestArtifactRecord> {
let relative_path: PathBuf = match artifact_path.strip_prefix(tests_root) {
Ok(relative) => normalized_relative_path(relative),
Err(_) => return None,
};
let metadata: Metadata = metadata(artifact_path)?;
let content_sha256: String = file_sha256(artifact_path)?;
if !is_safe_relative_sql_path(&relative_path) {
return None;
}
Some(SqlTestArtifactRecord {
identity: identity.to_owned(),
relative_path,
size: metadata.len(),
mtime_ns: mtime_ns(&metadata)?,
content_sha256,
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
//! Whether a cached SQL test artifact is unchanged and can be kept.

use std::fs::Metadata;
use std::path::{Path, PathBuf};

use crate::artifacts::_helpers::sql_test_records::{
file_sha256, is_safe_relative_sql_path, metadata, mtime_ns,
};
use crate::artifacts::models::SqlTestArtifactRecord;

/// The record with its artifact's current mtime, when the artifact is unchanged.
#[must_use]
pub fn reusable_sql_test_record(
tests_root: &Path,
record: &SqlTestArtifactRecord,
identity: &str,
) -> Option<SqlTestArtifactRecord> {
if record.identity != identity || !is_safe_relative_sql_path(&record.relative_path) {
return None;
}
let artifact_path: PathBuf = tests_root.join(&record.relative_path);
let metadata: Metadata = metadata(&artifact_path)?;
if !metadata.is_file() || metadata.len() != record.size {
return None;
}
let current_mtime_ns: i64 = mtime_ns(&metadata)?;
if current_mtime_ns == record.mtime_ns {
return Some(record.clone());
}
if file_sha256(&artifact_path)? != record.content_sha256 {
return None;
}
Some(SqlTestArtifactRecord {
mtime_ns: current_mtime_ns,
..record.clone()
})
}
24 changes: 24 additions & 0 deletions crates/sqlbuild-cache/src/artifacts/main/stored_sql_test_record.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
//! A SQL test artifact record read back from the cache file.

use std::path::{Path, PathBuf};

use crate::artifacts::_helpers::sql_test_records::{
is_safe_relative_sql_path, normalized_relative_path,
};
use crate::artifacts::models::{SqlTestArtifactRecord, StoredSqlTestRecord};

/// The stored record, or `None` when its path could leave the tests directory.
#[must_use]
pub fn stored_sql_test_record(stored: StoredSqlTestRecord) -> Option<SqlTestArtifactRecord> {
let relative_path: PathBuf = normalized_relative_path(Path::new(&stored.relative_path));
if !is_safe_relative_sql_path(&relative_path) {
return None;
}
Some(SqlTestArtifactRecord {
identity: stored.identity,
relative_path,
size: stored.size,
mtime_ns: stored.mtime_ns,
content_sha256: stored.content_sha256,
})
}
36 changes: 36 additions & 0 deletions crates/sqlbuild-cache/src/artifacts/models.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,39 @@ pub enum Publication {
/// Each staged file was published at its compiled path, in this order.
Files(Vec<(PathBuf, PathBuf)>),
}

/// What reusing one compiled SQL test artifact requires: its identity, place and content.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SqlTestArtifactRecord {
/// The digest of everything the artifact was rendered from.
pub identity: String,
/// The artifact's path below the compiled tests directory.
pub relative_path: PathBuf,
pub size: u64,
pub mtime_ns: i64,
/// The lowercase hex SHA-256 of the artifact's bytes.
pub content_sha256: String,
}

/// The fields of one cache file record, already checked to be strings and non-negative integers.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct StoredSqlTestRecord {
pub identity: String,
/// The `relative_path` text as stored.
pub relative_path: String,
pub size: u64,
pub mtime_ns: i64,
pub content_sha256: String,
}

impl SqlTestArtifactRecord {
/// The relative path's `as_posix()` text, as the cache file stores it.
#[must_use]
pub fn relative_posix(&self) -> String {
self.relative_path
.components()
.map(|component| component.as_os_str().to_string_lossy().into_owned())
.collect::<Vec<String>>()
.join("/")
}
}
1 change: 1 addition & 0 deletions crates/sqlbuild-cache/src/digest/main/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ pub mod digest_files;
pub mod fingerprint_project_files;
pub mod hex_digest;
pub mod installed_code_identity;
pub mod sha256_hex;
12 changes: 12 additions & 0 deletions crates/sqlbuild-cache/src/digest/main/sha256_hex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
//! The SHA-256 text Python's `hashlib.sha256(data).hexdigest()` gives.

use sha2::{Digest, Sha256};

/// Lowercase hexadecimal SHA-256 of `bytes`.
#[must_use]
pub fn sha256_hex(bytes: &[u8]) -> String {
Sha256::digest(bytes)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
123 changes: 123 additions & 0 deletions crates/sqlbuild-core/src/cli_text/_helpers/diagnostics.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
//! A diagnostic's human text: header, resource, located source with carets, notes and help.

use crate::cli_text::models::{CliStyle, DiagnosticText, LocationText};
use crate::text::main::python_splitlines::python_splitlines;

const ERROR_SEVERITY: &str = "error";
const WARNING_SEVERITY: &str = "warning";
const MODEL_RESOURCE: &str = "model";

/// Every shown diagnostic, separated by blank lines, with folded warning counts.
pub(crate) fn diagnostics_text(diagnostics: &[DiagnosticText], style: CliStyle) -> String {
let mut lines: Vec<String> = Vec::new();
for diagnostic in diagnostics {
lines.extend(diagnostic_lines(diagnostic, style));
if diagnostic.occurrences > 1 {
lines.push(format!(
" = {} occurrences; all retained in JSON and the warning count",
diagnostic.occurrences
));
}
lines.push(String::new());
}
let _ = lines.pop();
lines.join("\n")
}

fn diagnostic_lines(diagnostic: &DiagnosticText, style: CliStyle) -> Vec<String> {
let header: String = format!(
"{}[{}]: {}",
diagnostic.severity, diagnostic.code, diagnostic.message
);
let mut lines: Vec<String> = vec![match diagnostic.severity.as_str() {
ERROR_SEVERITY => style.error(&header),
WARNING_SEVERITY => style.warning(&header),
_ => style.muted(&header),
}];
if let Some(resource) = &diagnostic.resource_name {
let label: String = match &diagnostic.resource_type {
Some(kind) if kind != MODEL_RESOURCE => kind.replace('_', " "),
_ => MODEL_RESOURCE.to_owned(),
};
lines.push(format!(" {label}: {}", style.object_name(resource)));
}
if let Some(location) = &diagnostic.location {
lines.extend(location_block(location, None));
} else if let Some(path) = &diagnostic.path {
lines.push(format!(" --> {path}"));
}
for related in &diagnostic.related {
lines.push(String::new());
lines.push(format!(" {}:", related.label));
lines.extend(location_block(
&related.location,
related.message.as_deref(),
));
}
lines.extend(
diagnostic
.notes
.iter()
.map(|note| format!(" {} {note}", style.muted("= note:"))),
);
if !diagnostic.affected_rules.is_empty() {
lines.push(format!(
" {} {}",
style.muted("= affected Rules:"),
diagnostic.affected_rules.join(", ")
));
}
if let Some(help) = &diagnostic.help {
lines.push(format!(" {} {help}", style.muted("= help:")));
}
lines
}

/// ` --> path:line:column`, then the source line with carets when the line exists.
fn location_block(location: &LocationText, message: Option<&str>) -> Vec<String> {
let mut lines: Vec<String> = vec![format!(
" --> {}:{}:{}",
location.path, location.line, location.column
)];
let Some(source) = &location.source else {
return lines;
};
let source_lines: Vec<&str> = python_splitlines(source);
let Ok(line_index) = usize::try_from(location.line) else {
return lines;
};
if !(1..=source_lines.len()).contains(&line_index) {
return lines;
}
let source_line: &str = source_lines[line_index - 1];
let line_number: String = location.line.to_string();
let gutter: String = " ".repeat(line_number.chars().count());
lines.push(format!(" {gutter} |"));
lines.push(format!(" {line_number} | {source_line}"));
lines.push(format!(
" {gutter} | {}",
caret_line(location, source_line, message)
));
lines
}

fn caret_line(location: &LocationText, source_line: &str, message: Option<&str>) -> String {
let start_column: i64 = location.column.max(1);
let mut end_column: i64 = match location.end_column {
Some(column) if column != 0 => column,
_ => start_column + 1,
};
if location
.end_line
.is_some_and(|end_line| end_line != location.line)
{
end_column = i64::try_from(source_line.chars().count()).unwrap_or(i64::MAX) + 1;
}
let caret_count: i64 = (end_column - start_column).max(1);
let suffix: String = message.map_or_else(String::new, |message| format!(" {message}"));
format!(
"{}{}{suffix}",
" ".repeat(usize::try_from(start_column - 1).unwrap_or_default()),
"^".repeat(usize::try_from(caret_count).unwrap_or_default())
)
}
Loading
Loading