use std::io::Cursor;
use std::path::{Path, PathBuf};
use crate::dicomweb::{DicomWebDump, is_dicom_record};
use crate::meta::make_row_from_dicom_metadata;
use crate::reader::{read_dcm_file, read_dcm_stream};
use crate::dcm;
use dicom::object::DefaultDicomObject;
use dicom::object::StandardDataDictionary;
use indexmap::IndexMap;
use nu_plugin::{EngineInterface, Plugin, PluginCommand};
use nu_protocol::{Category, Example, LabeledError, PipelineData, Record, ShellError, Signature, Span, SyntaxShape, Value};
#[derive(Default)]
pub struct DcmPlugin {
pub dcm_dictionary: StandardDataDictionary,
}
impl Plugin for DcmPlugin {
fn version(&self) -> String {
env!("CARGO_PKG_VERSION").to_string()
}
fn commands(&self) -> Vec<Box<dyn nu_plugin::PluginCommand<Plugin = Self>>> {
vec![Box::new(DcmPluginCommand)]
}
}
#[derive(Default)]
pub struct DcmPluginCommand;
impl PluginCommand for DcmPluginCommand {
type Plugin = DcmPlugin;
fn name(&self) -> &str {
"dcm"
}
fn description(&self) -> &str {
"Parse DICOM object from file or binary data. Invalid DICOM objects are reported as errors and excluded from the output."
}
fn signature(&self) -> Signature {
Signature::build(nu_plugin::PluginCommand::name(self))
.named(
"error",
SyntaxShape::String,
"If an error occurs when Dicom object is parsed, the error message will be inserted in this column instead producing an error result.",
Some('e'))
.category(Category::Formats) .search_terms(vec!["dicom".to_string(), "medical".to_string(), "parse".to_string()])
.description("Parse DICOM files and binary data")
.extra_description("Parse DICOM objects from files or binary data. Invalid DICOM objects are reported as errors and excluded from the output unless --error flag is used.")
}
fn examples(&self) -> Vec<Example> {
vec![
Example {
description: "Parse a DICOM file by passing binary data",
example: "open file.dcm | dcm",
result: Some(Value::test_record(Record::from_iter([
("PatientName".to_string(), Value::test_string("John Doe")),
("Modality".to_string(), Value::test_string("CT")),
("StudyDate".to_string(), Value::test_string("20231201")),
("ImageType".to_string(), Value::test_string("ORIGINAL\\PRIMARY")),
]))),
},
Example { description: "Parse DICOM files from a list", example: "ls *.dcm | dcm", result: None },
Example { description: "Parse a specific file by filename", example: "\"file.dcm\" | dcm", result: None },
Example { description: "Parse with error handling", example: "ls *.dcm | dcm --error parse_error", result: None },
]
}
fn run(
&self,
plugin: &DcmPlugin,
engine: &EngineInterface,
call: &nu_plugin::EvaluatedCall,
input: PipelineData,
) -> Result<PipelineData, LabeledError> {
let current_dir = engine
.get_current_dir()
.map(PathBuf::from);
let span = input
.span()
.unwrap_or(call.head);
let input_metadata = input.metadata();
let input = if let PipelineData::ByteStream(byte_stream, ..) = input {
let bytes = byte_stream.into_bytes()?;
Value::binary(bytes, span)
} else {
input.into_value(span)?
};
let error_column = call.get_flag_value("error");
let error_column = if let Some(Value::String { val, .. }) = error_column {
Some(val)
} else {
None
};
let output = self.run_filter(plugin, current_dir.as_deref(), &input, error_column)?;
let output_metadata = input_metadata.map(|m| m.with_content_type(None));
Ok(PipelineData::Value(output, output_metadata))
}
}
impl DcmPluginCommand {
pub fn run_filter(
&self,
plugin: &DcmPlugin,
current_dir: Result<&Path, &ShellError>,
value: &Value,
error_column: Option<String>,
) -> Result<Value, LabeledError> {
self.process_value(plugin, current_dir, value, &error_column)
}
fn process_value(
&self,
plugin: &DcmPlugin,
current_dir: Result<&Path, &ShellError>,
value: &Value,
error_column: &Option<String>,
) -> Result<Value, LabeledError> {
let result = self.process_value_with_normal_error(plugin, current_dir, value, error_column);
match (error_column, &result) {
(Some(error_column), Err(err)) => Ok(Value::record(
Record::from_raw_cols_vals(
vec![error_column.to_string()],
vec![Value::string(
err.msg
.to_string(),
value.span(),
)],
Span::unknown(),
Span::unknown(),
)?,
value.span(),
)),
_ => result,
}
}
fn process_value_with_normal_error(
&self,
plugin: &DcmPlugin,
current_dir: Result<&Path, &ShellError>,
value: &Value,
error_column: &Option<String>,
) -> Result<Value, LabeledError> {
match &value {
Value::String { val, internal_span, .. } => {
let file = resolve_path(val, current_dir, value.span())?;
let obj = read_dcm_file(&file).map_err(|e| {
let text = if val.get(128..132) == Some("DICM") {
"Input string looks like DICOM binary data. Either pass binary data, or a filename.".to_string()
} else {
format!("{} [file {}]", e, file.to_string_lossy())
};
LabeledError::new("`dcm` expects valid DICOM binary data").with_label(text, *internal_span)
})?;
self.process_dicom_object(plugin, internal_span, obj, error_column)
}
Value::Record { val, internal_span } => {
let record_type = get_record_string(val, "type");
let record_name = get_record_string(val, "name");
if let (Some(record_type), Some(record_name)) = (record_type, record_name) {
if record_type == "file" {
let file = resolve_path(record_name, current_dir, value.span())?;
let obj = read_dcm_file(&file).map_err(|e| {
let text = format!("{} [file {}]", e, file.to_string_lossy());
LabeledError::new("`dcm` expects valid DICOM binary data").with_label(text, *internal_span)
})?;
return self.process_dicom_object(plugin, internal_span, obj, error_column);
}
}
if record_name.is_none() && record_type.is_none() && is_dicom_record(val) {
let dcm_dumper = DicomWebDump::with_dictionary(&plugin.dcm_dictionary);
let result = dcm_dumper
.process_dicomweb_record(val, *internal_span)
.map_err(|e| LabeledError::new("Failed to proess DicomWeb record").with_label(e.to_string(), e.span()))?;
return Ok(result);
}
Err(LabeledError::new("Cannot process records directly, unless they are DicomWeb records")
.with_label("For files, select file name or binary data from the record before passing it to dcm", *internal_span))
}
Value::Binary { val, internal_span, .. } => {
let cursor = Cursor::new(val);
let obj = read_dcm_stream(cursor).map_err(|e| LabeledError::new("Invalid DICOM data").with_label(e.to_string(), *internal_span))?;
self.process_dicom_object(plugin, internal_span, obj, error_column)
}
Value::List { vals, internal_span, .. } => {
let result: Vec<Value> = vals
.iter()
.map(|v| {
self.process_value(plugin, current_dir, v, error_column)
.unwrap_or_else(|e| Value::error(e.into(), *internal_span))
})
.collect();
Ok(Value::list(result, *internal_span))
}
_ => Err(LabeledError::new("Unrecognized type in stream")
.with_label("'dcm' expects a string (filepath), binary, or column path", value.span())),
}
}
fn process_dicom_object(
&self,
plugin: &DcmPlugin,
span: &Span,
obj: DefaultDicomObject,
error_column: &Option<String>,
) -> Result<Value, LabeledError> {
let dcm_dumper = dcm::DicomDump { dcm_dictionary: &plugin.dcm_dictionary };
let mut index_map = IndexMap::with_capacity(1000);
if let Some(error_column) = error_column {
index_map.insert(error_column.to_string(), Value::string(String::new(), *span));
}
make_row_from_dicom_metadata(span, &mut index_map, obj.meta());
dcm_dumper.make_row_from_dicom_object(span, &mut index_map, &obj);
Ok(Value::record(Record::from_iter(index_map), *span))
}
}
fn get_record_string<'a>(
record: &'a Record,
field_name: &str,
) -> Option<&'a str> {
let value = record.get(field_name)?;
let Value::String { val, .. } = value else {
return None;
};
Some(val.as_str())
}
fn resolve_path(
filename: &str,
current_dir: Result<&Path, &ShellError>,
span: Span,
) -> Result<PathBuf, LabeledError> {
use std::path::Path;
let path = Path::new(filename);
if path.is_absolute() {
return Ok(path.to_path_buf());
}
let current_dir = current_dir.map_err(|e| {
LabeledError::new("Failed to get current working directory")
.with_label(format!("Cannot resolve relative path '{filename}'\n\nError: {e}"), span)
})?;
Ok(PathBuf::from(current_dir).join(filename))
}