use super::*;
#[derive(Debug, Clone)]
pub(super) struct ReadActivityTarget {
pub(super) target: String,
selector_start: Option<usize>,
has_canonical_metadata: bool,
}
pub(super) fn compact_read_activity_detail(
detail: &crate::output::ToolActivityDetail,
) -> Option<String> {
if let Some((offset, targets)) = canonical_read_activity_targets(&detail.metadata) {
return compact_read_activity_targets(&targets, Some(offset));
}
if matches!(
detail.status,
ActivityStatus::Queued | ActivityStatus::Running | ActivityStatus::Writing
) {
return None;
}
compact_read_activity_request(&detail.params)
}
fn canonical_read_activity_targets(value: &Value) -> Option<(usize, Vec<ReadActivityTarget>)> {
let root = value.as_object()?;
let offset = positive_read_activity_number(root.get("offset"))?;
let entries = match root.get("results") {
Some(results) => results.as_array()?.iter().collect::<Vec<_>>(),
None => vec![value],
};
let targets = entries
.into_iter()
.filter_map(|entry| {
let object = entry.as_object()?;
let requested_target = first_nonempty_read_activity_string(object, &["requested_path"]);
let metadata_target = first_nonempty_read_activity_string(object, &["source", "path"]);
let failed = object.get("success").and_then(Value::as_bool) == Some(false);
let target = if failed {
requested_target.or(metadata_target)?
} else {
metadata_target.or(requested_target)?
};
let fallback_raw = match object.get("raw") {
None => Some(false),
Some(Value::Bool(raw)) => Some(*raw),
Some(_) => None,
};
let selector_metadata = fallback_raw.and_then(|fallback_raw| {
read_activity_selector_metadata(object.get("selector"), fallback_raw)
});
Some(ReadActivityTarget {
target: target.to_string(),
selector_start: selector_metadata.and_then(|metadata| metadata.start),
has_canonical_metadata: !failed
&& metadata_target.is_some()
&& selector_metadata.is_some(),
})
})
.collect::<Vec<_>>();
(!targets.is_empty()).then_some((offset, targets))
}
fn first_nonempty_read_activity_string<'a>(
object: &'a serde_json::Map<String, Value>,
keys: &[&str],
) -> Option<&'a str> {
keys.iter().find_map(|key| {
object
.get(*key)
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
})
}
fn positive_read_activity_number(value: Option<&Value>) -> Option<usize> {
value?
.as_u64()
.and_then(|value| usize::try_from(value).ok())
.filter(|value| *value > 0)
}
#[derive(Debug, Clone, Copy)]
pub(super) struct ReadActivitySelector {
start: Option<usize>,
}
fn read_activity_selector_metadata(
selector: Option<&Value>,
fallback_raw: bool,
) -> Option<ReadActivitySelector> {
let selector = selector?;
if selector.is_null() {
return Some(ReadActivitySelector {
start: fallback_raw.then_some(1),
});
}
let selector = selector.as_object()?;
let raw = match selector.get("raw") {
None => fallback_raw,
Some(Value::Bool(raw)) => *raw,
Some(_) => return None,
};
let lines = match selector.get("lines") {
None | Some(Value::Null) => {
return Some(ReadActivitySelector {
start: raw.then_some(1),
});
}
Some(Value::Object(lines)) => lines,
Some(_) => return None,
};
let start = match lines.get("type").and_then(Value::as_str)? {
"single" => positive_read_activity_number(lines.get("line"))?,
"range" => {
let start = positive_read_activity_number(lines.get("start"))?;
let end = positive_read_activity_number(lines.get("end"))?;
(end >= start).then_some(start)?
}
"from_start" => {
positive_read_activity_number(lines.get("end"))?;
1
}
"from_line" => positive_read_activity_number(lines.get("start"))?,
"count" => {
let start = positive_read_activity_number(lines.get("start"))?;
positive_read_activity_number(lines.get("count"))?;
start
}
_ => return None,
};
Some(ReadActivitySelector { start: Some(start) })
}
fn compact_read_activity_request(params: &Value) -> Option<String> {
let params = params.as_object()?;
let targets = match params.get("paths") {
Some(paths) if !paths.is_null() => {
if params.get("path").is_some_and(|path| !path.is_null()) {
return None;
}
paths
.as_array()?
.iter()
.map(Value::as_str)
.collect::<Option<Vec<_>>>()?
}
Some(Value::Null) | None => vec![params.get("path")?.as_str()?],
Some(_) => return None,
};
if targets.is_empty() {
return None;
}
let targets = targets
.into_iter()
.map(|target| ReadActivityTarget {
target: target.to_string(),
selector_start: None,
has_canonical_metadata: false,
})
.collect::<Vec<_>>();
compact_read_activity_targets(&targets, None)
}
fn compact_read_activity_targets(
targets: &[ReadActivityTarget],
offset: Option<usize>,
) -> Option<String> {
let sanitized_targets = targets
.iter()
.map(|target| sanitize_read_activity_target(&target.target))
.collect::<Vec<_>>();
let summaries = sanitized_targets
.iter()
.enumerate()
.filter_map(|(index, target)| {
if target.is_empty() {
return None;
}
let display_target = if is_read_activity_resource(target) {
target.clone()
} else {
shortest_unique_activity_path_suffix(index, &sanitized_targets, true)
};
if display_target.is_empty() {
return None;
}
let start = if targets[index].has_canonical_metadata {
targets[index].selector_start.or_else(|| {
if is_read_activity_resource(target) {
None
} else {
offset
}
})
} else {
None
};
Some(match start {
Some(start) => format!("{display_target}:{start}"),
None => display_target,
})
})
.collect::<Vec<_>>();
let mut summary = summaries
.iter()
.take(2)
.cloned()
.collect::<Vec<_>>()
.join(", ");
if summary.is_empty() {
return None;
}
if targets.len() > 2 {
summary.push_str(" …");
}
Some(summary)
}
fn is_read_activity_resource(target: &str) -> bool {
target.contains("://")
}
pub(super) fn shortest_unique_activity_path_suffix(
index: usize,
targets: &[String],
preserve_resources: bool,
) -> String {
let target = &targets[index];
if preserve_resources && is_read_activity_resource(target) {
return target.clone();
}
let segments = activity_path_segments(target);
for length in 1..=segments.len() {
let candidate = segments[segments.len() - length..].join("/");
let suffix = &segments[segments.len() - length..];
let collides = targets.iter().enumerate().any(|(other_index, other)| {
if other_index == index
|| other.is_empty()
|| (preserve_resources && is_read_activity_resource(other))
{
return false;
}
let other_segments = activity_path_segments(other);
other_segments.len() >= length
&& other_segments[other_segments.len() - length..] == *suffix
});
if !collides {
return candidate;
}
}
segments.join("/")
}
fn activity_path_segments(target: &str) -> Vec<&str> {
target
.trim_end_matches(['/', '\\'])
.split(['/', '\\'])
.filter(|segment| !segment.is_empty())
.collect()
}
pub(crate) fn sanitize_read_activity_label(label: &str) -> String {
let label = crate::output::sanitize_display_text(label);
label.strip_prefix("read ").map_or_else(
|| sanitize_read_activity_target(&label),
|target| format!("read {}", sanitize_read_activity_target(target)),
)
}
pub(super) fn sanitize_read_activity_target(target: &str) -> String {
let sanitized = crate::output::sanitize_display_text(target)
.chars()
.filter(|character| !character.is_control())
.collect::<String>();
let trimmed = sanitized.trim();
if is_http_activity_url(trimmed) {
if let Ok(mut url) = reqwest::Url::parse(trimmed) {
let _ = url.set_username("");
let _ = url.set_password(None);
url.set_query(None);
url.set_fragment(None);
return normalize_inline_separators(url.as_ref());
}
return normalize_inline_separators(&strip_http_activity_url_parts(trimmed));
}
normalize_inline_separators(&sanitized)
}
fn is_http_activity_url(target: &str) -> bool {
target
.get(.."http://".len())
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("http://"))
|| target
.get(.."https://".len())
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"))
}
fn strip_http_activity_url_parts(target: &str) -> String {
let mut safe = target
.split(['?', '#'])
.next()
.unwrap_or(target)
.to_string();
let Some(scheme_end) = safe.find("://") else {
return safe;
};
let authority_start = scheme_end + 3;
let authority_end = safe[authority_start..]
.find('/')
.map_or(safe.len(), |end| authority_start + end);
if let Some(userinfo_end) = safe[authority_start..authority_end].rfind('@') {
safe.replace_range(authority_start..authority_start + userinfo_end + 1, "");
}
safe
}