Skip to content

Commit 7ba1690

Browse files
Copilotphilnach
andcommitted
Remove interactive prompting and add validation for missing configuration
Co-authored-by: philnach <[email protected]>
1 parent 5f02b11 commit 7ba1690

File tree

2 files changed

+212
-40
lines changed

2 files changed

+212
-40
lines changed

Core/Cosmos.DataTransfer.Core.UnitTests/RunCommandTests.cs

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,5 +175,166 @@ public void Invoke_WithMultipleSources_ExecutesAllOperationsToSink()
175175
sourceExtension.Verify(se => se.ReadAsync(It.IsAny<IConfiguration>(), It.IsAny<ILogger>(), It.IsAny<CancellationToken>()), Times.Exactly(3));
176176
sinkExtension.Verify(se => se.WriteAsync(It.IsAny<IAsyncEnumerable<IDataItem>>(), It.Is<IConfiguration>(c => c["FilePath"] == targetFile), sourceExtension.Object, It.IsAny<ILogger>(), It.IsAny<CancellationToken>()), Times.Exactly(3));
177177
}
178+
179+
[TestMethod]
180+
public void Invoke_WithEmptySourceAndSink_ReturnsError()
181+
{
182+
IConfigurationRoot configuration = new ConfigurationBuilder()
183+
.AddInMemoryCollection(new Dictionary<string, string?>
184+
{
185+
{ "Source", "" },
186+
{ "Sink", "" },
187+
})
188+
.Build();
189+
var loader = new Mock<IExtensionLoader>();
190+
loader
191+
.Setup(l => l.LoadExtensions<IDataSourceExtension>(It.IsAny<CompositionContainer>()))
192+
.Returns(new List<IDataSourceExtension>());
193+
loader
194+
.Setup(l => l.LoadExtensions<IDataSinkExtension>(It.IsAny<CompositionContainer>()))
195+
.Returns(new List<IDataSinkExtension>());
196+
197+
var handler = new RunCommand.CommandHandler(loader.Object,
198+
configuration,
199+
NullLoggerFactory.Instance);
200+
201+
var parseResult = new RootCommand().Parse(Array.Empty<string>());
202+
var result = handler.Invoke(new InvocationContext(parseResult));
203+
204+
// Should return error code when source/sink are not configured
205+
Assert.AreEqual(1, result);
206+
}
207+
208+
[TestMethod]
209+
public void Invoke_WithMissingSource_ReturnsError()
210+
{
211+
const string sink = "testSink";
212+
IConfigurationRoot configuration = new ConfigurationBuilder()
213+
.AddInMemoryCollection(new Dictionary<string, string?>
214+
{
215+
{ "Sink", sink },
216+
})
217+
.Build();
218+
var loader = new Mock<IExtensionLoader>();
219+
var sinkExtension = new Mock<IDataSinkExtension>();
220+
sinkExtension.SetupGet(ds => ds.DisplayName).Returns(sink);
221+
loader
222+
.Setup(l => l.LoadExtensions<IDataSourceExtension>(It.IsAny<CompositionContainer>()))
223+
.Returns(new List<IDataSourceExtension>());
224+
loader
225+
.Setup(l => l.LoadExtensions<IDataSinkExtension>(It.IsAny<CompositionContainer>()))
226+
.Returns(new List<IDataSinkExtension> { sinkExtension.Object });
227+
228+
var handler = new RunCommand.CommandHandler(loader.Object,
229+
configuration,
230+
NullLoggerFactory.Instance);
231+
232+
var parseResult = new RootCommand().Parse(Array.Empty<string>());
233+
var result = handler.Invoke(new InvocationContext(parseResult));
234+
235+
// Should return error code when source is not configured
236+
Assert.AreEqual(1, result);
237+
}
238+
239+
[TestMethod]
240+
public void Invoke_WithMissingSink_ReturnsError()
241+
{
242+
const string source = "testSource";
243+
IConfigurationRoot configuration = new ConfigurationBuilder()
244+
.AddInMemoryCollection(new Dictionary<string, string?>
245+
{
246+
{ "Source", source },
247+
})
248+
.Build();
249+
var loader = new Mock<IExtensionLoader>();
250+
var sourceExtension = new Mock<IDataSourceExtension>();
251+
sourceExtension.SetupGet(ds => ds.DisplayName).Returns(source);
252+
loader
253+
.Setup(l => l.LoadExtensions<IDataSourceExtension>(It.IsAny<CompositionContainer>()))
254+
.Returns(new List<IDataSourceExtension> { sourceExtension.Object });
255+
loader
256+
.Setup(l => l.LoadExtensions<IDataSinkExtension>(It.IsAny<CompositionContainer>()))
257+
.Returns(new List<IDataSinkExtension>());
258+
259+
var handler = new RunCommand.CommandHandler(loader.Object,
260+
configuration,
261+
NullLoggerFactory.Instance);
262+
263+
var parseResult = new RootCommand().Parse(Array.Empty<string>());
264+
var result = handler.Invoke(new InvocationContext(parseResult));
265+
266+
// Should return error code when sink is not configured
267+
Assert.AreEqual(1, result);
268+
}
269+
270+
[TestMethod]
271+
public void Invoke_WithInvalidSourceExtension_ThrowsException()
272+
{
273+
const string source = "invalidSource";
274+
const string sink = "testSink";
275+
IConfigurationRoot configuration = new ConfigurationBuilder()
276+
.AddInMemoryCollection(new Dictionary<string, string?>
277+
{
278+
{ "Source", source },
279+
{ "Sink", sink },
280+
})
281+
.Build();
282+
var loader = new Mock<IExtensionLoader>();
283+
var sourceExtension = new Mock<IDataSourceExtension>();
284+
sourceExtension.SetupGet(ds => ds.DisplayName).Returns("differentSource");
285+
loader
286+
.Setup(l => l.LoadExtensions<IDataSourceExtension>(It.IsAny<CompositionContainer>()))
287+
.Returns(new List<IDataSourceExtension> { sourceExtension.Object });
288+
289+
var sinkExtension = new Mock<IDataSinkExtension>();
290+
sinkExtension.SetupGet(ds => ds.DisplayName).Returns(sink);
291+
loader
292+
.Setup(l => l.LoadExtensions<IDataSinkExtension>(It.IsAny<CompositionContainer>()))
293+
.Returns(new List<IDataSinkExtension> { sinkExtension.Object });
294+
295+
var handler = new RunCommand.CommandHandler(loader.Object,
296+
configuration,
297+
NullLoggerFactory.Instance);
298+
299+
var parseResult = new RootCommand().Parse(Array.Empty<string>());
300+
301+
// Should throw exception when source extension is not found
302+
Assert.ThrowsException<InvalidOperationException>(() => handler.Invoke(new InvocationContext(parseResult)));
303+
}
304+
305+
[TestMethod]
306+
public void Invoke_WithInvalidSinkExtension_ThrowsException()
307+
{
308+
const string source = "testSource";
309+
const string sink = "invalidSink";
310+
IConfigurationRoot configuration = new ConfigurationBuilder()
311+
.AddInMemoryCollection(new Dictionary<string, string?>
312+
{
313+
{ "Source", source },
314+
{ "Sink", sink },
315+
})
316+
.Build();
317+
var loader = new Mock<IExtensionLoader>();
318+
var sourceExtension = new Mock<IDataSourceExtension>();
319+
sourceExtension.SetupGet(ds => ds.DisplayName).Returns(source);
320+
loader
321+
.Setup(l => l.LoadExtensions<IDataSourceExtension>(It.IsAny<CompositionContainer>()))
322+
.Returns(new List<IDataSourceExtension> { sourceExtension.Object });
323+
324+
var sinkExtension = new Mock<IDataSinkExtension>();
325+
sinkExtension.SetupGet(ds => ds.DisplayName).Returns("differentSink");
326+
loader
327+
.Setup(l => l.LoadExtensions<IDataSinkExtension>(It.IsAny<CompositionContainer>()))
328+
.Returns(new List<IDataSinkExtension> { sinkExtension.Object });
329+
330+
var handler = new RunCommand.CommandHandler(loader.Object,
331+
configuration,
332+
NullLoggerFactory.Instance);
333+
334+
var parseResult = new RootCommand().Parse(Array.Empty<string>());
335+
336+
// Should throw exception when sink extension is not found
337+
Assert.ThrowsException<InvalidOperationException>(() => handler.Invoke(new InvocationContext(parseResult)));
338+
}
178339
}
179340
}

Core/Cosmos.DataTransfer.Core/RunCommand.cs

Lines changed: 51 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -77,13 +77,45 @@ public async Task<int> InvokeAsync(InvocationContext context)
7777
try
7878
{
7979
var configuredOptions = _configuration.Get<DataTransferOptions>() ?? new DataTransferOptions();
80+
var settingsPath = Settings?.FullName ?? configuredOptions.SettingsPath;
81+
82+
// Check if settings file exists when no source/sink provided via command line
83+
if (string.IsNullOrEmpty(Source) && string.IsNullOrEmpty(Sink))
84+
{
85+
var defaultSettingsPath = settingsPath ?? "migrationsettings.json";
86+
if (!File.Exists(defaultSettingsPath))
87+
{
88+
Console.Error.WriteLine($"Error: Settings file '{defaultSettingsPath}' not found.");
89+
Console.Error.WriteLine("Please provide a valid settings file or specify --source and --sink options.");
90+
Console.Error.WriteLine();
91+
Console.Error.WriteLine("Use --help for more information.");
92+
return 1;
93+
}
94+
}
95+
8096
var combinedConfig = await BuildSettingsConfiguration(_configuration,
81-
Settings?.FullName ?? configuredOptions.SettingsPath,
82-
string.IsNullOrEmpty(Source ?? configuredOptions.Source) && string.IsNullOrEmpty(Sink ?? configuredOptions.Sink),
97+
settingsPath,
8398
cancellationToken);
8499

85100
var options = combinedConfig.Get<DataTransferOptions>();
86101

102+
// Validate that we have source and sink configured
103+
var sourceValue = Source ?? options?.Source;
104+
var sinkValue = Sink ?? options?.Sink;
105+
106+
if (string.IsNullOrWhiteSpace(sourceValue) || string.IsNullOrWhiteSpace(sinkValue))
107+
{
108+
Console.Error.WriteLine("Error: Invalid configuration. Both Source and Sink must be specified.");
109+
if (!string.IsNullOrEmpty(settingsPath) && File.Exists(settingsPath))
110+
{
111+
Console.Error.WriteLine($"The settings file '{settingsPath}' is missing required Source or Sink values.");
112+
}
113+
Console.Error.WriteLine("Please provide valid Source and Sink in the settings file or via command line arguments.");
114+
Console.Error.WriteLine();
115+
Console.Error.WriteLine("Use --help for more information.");
116+
return 1;
117+
}
118+
87119
string extensionsPath = _extensionLoader.GetExtensionFolderPath();
88120
CompositionContainer container = _extensionLoader.BuildExtensionCatalog(extensionsPath);
89121

@@ -92,9 +124,9 @@ public async Task<int> InvokeAsync(InvocationContext context)
92124

93125
cancellationToken.ThrowIfCancellationRequested();
94126

95-
var source = await GetExtensionSelection(Source ?? options.Source, sources, "Source", cancellationToken);
127+
var source = await GetExtensionSelection(sourceValue, sources, "Source", cancellationToken);
96128
cancellationToken.ThrowIfCancellationRequested();
97-
var sink = await GetExtensionSelection(Sink ?? options.Sink, sinks, "Sink", cancellationToken);
129+
var sink = await GetExtensionSelection(sinkValue, sinks, "Sink", cancellationToken);
98130
cancellationToken.ThrowIfCancellationRequested();
99131

100132
var sourceConfig = combinedConfig.GetSection("SourceSettings");
@@ -191,57 +223,36 @@ private async Task<bool> ExecuteDataTransferOperation(IDataSourceExtension sourc
191223
private static async Task<T> GetExtensionSelection<T>(string? selectionName, List<T> extensions, string inputPrompt, CancellationToken cancellationToken)
192224
where T : class, IDataTransferExtension
193225
{
194-
if (!string.IsNullOrWhiteSpace(selectionName))
195-
{
196-
var extension = extensions.FirstOrDefault(s => s.MatchesExtensionSelection(selectionName));
197-
if (extension != null)
198-
{
199-
Console.WriteLine($"Using {extension.DisplayName} {inputPrompt}");
200-
return extension;
201-
}
202-
}
203-
204-
Console.WriteLine($"Select {inputPrompt}");
205-
for (var index = 0; index < extensions.Count; index++)
226+
await Task.CompletedTask; // Maintain async signature for compatibility
227+
cancellationToken.ThrowIfCancellationRequested();
228+
229+
if (string.IsNullOrWhiteSpace(selectionName))
206230
{
207-
var extension = extensions[index];
208-
Console.WriteLine($"{index + 1}:{extension.DisplayName}");
231+
throw new InvalidOperationException($"{inputPrompt} extension name is required. Use --source and --sink options or configure them in the settings file.");
209232
}
210233

211-
string? selection = "";
212-
int input;
213-
while (!int.TryParse(selection, out input) || input > extensions.Count || input <= 0)
234+
var extension = extensions.FirstOrDefault(s => s.MatchesExtensionSelection(selectionName));
235+
if (extension == null)
214236
{
215-
cancellationToken.ThrowIfCancellationRequested();
216-
selection = await Console.In.ReadLineAsync(cancellationToken);
237+
throw new InvalidOperationException($"{inputPrompt} extension '{selectionName}' not found. Use 'dmt list' to see available extensions.");
217238
}
218-
219-
T selected = extensions[input - 1];
220-
Console.WriteLine($"Using {selected.DisplayName} {inputPrompt}");
221-
return selected;
239+
240+
Console.WriteLine($"Using {extension.DisplayName} {inputPrompt}");
241+
return extension;
222242
}
223243

224-
private async Task<IConfiguration> BuildSettingsConfiguration(IConfiguration configuration, string? settingsPath, bool promptForFile, CancellationToken cancellationToken)
244+
private async Task<IConfiguration> BuildSettingsConfiguration(IConfiguration configuration, string? settingsPath, CancellationToken cancellationToken)
225245
{
246+
await Task.CompletedTask; // Maintain async signature for compatibility
247+
cancellationToken.ThrowIfCancellationRequested();
248+
226249
IConfigurationBuilder configurationBuilder = new ConfigurationBuilder();
227250
if (!string.IsNullOrEmpty(settingsPath) && File.Exists(settingsPath))
228251
{
229252
var fullFilePath = Path.GetFullPath(settingsPath);
230253
_logger.LogInformation("Settings loading from file at configured path '{FilePath}'.", fullFilePath);
231254
configurationBuilder = configurationBuilder.AddJsonFile(fullFilePath);
232255
}
233-
else if (promptForFile)
234-
{
235-
Console.Write("Path to settings file? (leave empty to skip): ");
236-
var path = await Console.In.ReadLineAsync(cancellationToken);
237-
cancellationToken.ThrowIfCancellationRequested();
238-
if (!string.IsNullOrWhiteSpace(path))
239-
{
240-
var fullFilePath = Path.GetFullPath(path);
241-
_logger.LogInformation("Settings loading from file at entered path '{FilePath}'.", fullFilePath);
242-
configurationBuilder = configurationBuilder.AddJsonFile(fullFilePath);
243-
}
244-
}
245256

246257
return configurationBuilder
247258
.AddConfiguration(configuration)

0 commit comments

Comments
 (0)